Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions dailybot_cli/commands/authoring_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,19 @@ def question_extras_options(func: Any) -> Any:
return func


def require_questions(questions: list[dict[str, Any]] | None, resource: str) -> None:
"""Fail fast when a create call has no questions.

The server requires at least one question at create time (``questions_required``);
``resource`` is ``"form"`` or ``"check-in"`` for the message.
"""
if not questions:
raise AuthoringError(
f"A {resource} must have at least one question. Seed questions with "
"--questions-file or --interactive."
)


def check_report_channels(report_channels: tuple[str, ...]) -> None:
"""Fail fast when more than ``MAX_REPORT_CHANNELS`` channels are attached.

Expand Down
14 changes: 8 additions & 6 deletions dailybot_cli/commands/checkin.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
parse_schedule,
prompt_participants_interactively,
question_extras_options,
require_questions,
require_short_question,
require_short_questions,
resolve_question_extras,
Expand Down Expand Up @@ -437,10 +438,11 @@ def checkin_create(
"""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.
Creating check-ins is role-gated server-side (admins/managers). A check-in must
have at least one question at create time — seed them with --questions-file or
--interactive (add/edit/remove more later with `dailybot checkin questions`). The
scheduling/behavior flags (frequency, reminders, timezone mode, submission rules,
privacy) mirror the web UI.

\b
Examples:
Expand Down Expand Up @@ -475,8 +477,8 @@ def checkin_create(
questions = parse_questions_file(questions_file)
else:
questions = None
if questions:
require_short_questions(questions, ai_short_question)
require_questions(questions, "check-in")
require_short_questions(questions or [], ai_short_question)
try:
with console.status("Creating check-in..."):
result: dict[str, Any] = client.create_checkin(
Expand Down
19 changes: 10 additions & 9 deletions dailybot_cli/commands/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
parse_options,
parse_questions_file,
question_extras_options,
require_questions,
require_short_question,
require_short_questions,
resolve_form_config,
Expand Down Expand Up @@ -550,15 +551,15 @@ def form_create(
json_mode: bool,
**config_flags: Any,
) -> None:
"""Create a form (optionally seeded with questions and full config).
"""Create a form (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.
Creating forms is role-gated server-side (admins/managers as applicable). A form
must have at least one question at create time — seed them with --questions-file
or --interactive (add/edit/remove more later via `dailybot form questions`). 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:
Expand All @@ -575,8 +576,8 @@ def form_create(
questions = parse_questions_file(questions_file)
else:
questions = None
if questions:
require_short_questions(questions, ai_short_question)
require_questions(questions, "form")
require_short_questions(questions or [], ai_short_question)
config: dict[str, Any] = resolve_form_config(client, **config_flags)
try:
with console.status("Creating form..."):
Expand Down
4 changes: 4 additions & 0 deletions dailybot_cli/commands/public_api_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@
"--ai-short-question to let Dailybot generate it."
),
"questions_limit_exceeded": "Too many questions (the limit is 50).",
"questions_required": (
"At least one question is required. Seed questions with --questions-file or --interactive."
),
"invalid_question_data": "A question payload is malformed. Check the question fields.",
"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).",
Expand Down
4 changes: 2 additions & 2 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,15 +212,15 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au
| `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 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. A form must have **at least one question** at create time (`questions_required`) — seed with `--questions-file`/`--interactive`. 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).
**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`); both check-ins and forms **require at least one question at create time** (`questions_required`) — the CLI fails fast if you create without `--questions-file`/`--interactive`. 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. |
Expand Down
18 changes: 15 additions & 3 deletions tests/authoring_lifecycle_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@ def test_full_form_authoring_flow(self, runner: CliRunner, tmp_path: Any) -> Non


class TestCheckinLifecycle:
def test_full_checkin_authoring_flow(self, runner: CliRunner) -> None:
def test_full_checkin_authoring_flow(self, runner: CliRunner, tmp_path: Any) -> None:
qfile = tmp_path / "q.json"
qfile.write_text(
json.dumps([{"question_type": "text", "question": "Q?", "short_question": "Q"}])
)
checkin: dict[str, Any] = {"id": "fu-1", "name": "Standup", "questions": []}
with _auth(), _client() as cls:
client: MagicMock = cls.return_value
Expand All @@ -117,6 +121,8 @@ def test_full_checkin_authoring_flow(self, runner: CliRunner) -> None:
"1,2,3",
"--team",
"Eng",
"--questions-file",
str(qfile),
],
[
"checkin",
Expand Down Expand Up @@ -202,10 +208,16 @@ def test_invalid_question_type_400ish(self, runner: CliRunner) -> None:


class TestJsonMode:
def test_create_form_json(self, runner: CliRunner) -> None:
def test_create_form_json(self, runner: CliRunner, tmp_path: Any) -> None:
qfile = tmp_path / "q.json"
qfile.write_text(
json.dumps([{"question_type": "text", "question": "Q?", "short_question": "Q"}])
)
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"])
result = runner.invoke(
cli, ["form", "create", "-n", "Retro", "--json", "--questions-file", str(qfile)]
)
assert result.exit_code == 0
assert json.loads(result.output)["id"] == "f-1"

Expand Down
11 changes: 9 additions & 2 deletions tests/authoring_security_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Security-focused tests for authoring commands: error mapping, confirmations,
no-secret-leakage, and client-side validation running before any network call."""

import json
from typing import Any
from unittest.mock import MagicMock, patch

Expand Down Expand Up @@ -124,14 +125,20 @@ def test_form_question_delete_prompts(self, runner: CliRunner) -> None:


class TestNoSecretLeakage:
def test_token_not_in_output(self, runner: CliRunner) -> None:
def test_token_not_in_output(self, runner: CliRunner, tmp_path: Any) -> None:
secret: str = "super-secret-bearer-token-value"
qfile = tmp_path / "q.json"
qfile.write_text(
json.dumps([{"question_type": "text", "question": "Q?", "short_question": "Q"}])
)
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"])
result = runner.invoke(
cli, ["form", "create", "-n", "Retro", "--questions-file", str(qfile)]
)
assert result.exit_code == 0
assert secret not in result.output
assert "super-secret-api-key" not in result.output
45 changes: 41 additions & 4 deletions tests/checkin_authoring_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for check-in authoring commands (create/config/archive/questions)."""

import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

Expand All @@ -22,6 +24,16 @@ def runner() -> CliRunner:
return CliRunner()


@pytest.fixture
def qfile(tmp_path: Path) -> str:
"""A minimal one-question file — questions are required at create time."""
path = tmp_path / "q.json"
path.write_text(
json.dumps([{"question_type": "text", "question": "Q?", "short_question": "Q"}])
)
return str(path)


def _auth() -> Any:
return patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok")

Expand All @@ -31,7 +43,7 @@ def _client() -> Any:


class TestCheckinCreate:
def test_create_with_schedule(self, runner: CliRunner) -> None:
def test_create_with_schedule(self, runner: CliRunner, qfile: str) -> None:
with _auth(), _client() as cls:
client: MagicMock = cls.return_value
client.create_checkin.return_value = CHECKIN_PAYLOAD
Expand All @@ -51,13 +63,15 @@ def test_create_with_schedule(self, runner: CliRunner) -> None:
"UTC",
"--team",
"Eng",
"--questions-file",
qfile,
],
)
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:
def test_create_forwards_config_flags(self, runner: CliRunner, qfile: str) -> None:
with _auth(), _client() as cls:
client: MagicMock = cls.return_value
client.create_checkin.return_value = CHECKIN_PAYLOAD
Expand All @@ -76,6 +90,8 @@ def test_create_forwards_config_flags(self, runner: CliRunner) -> None:
"--reminders",
"2",
"--no-future",
"--questions-file",
qfile,
],
)
assert result.exit_code == 0
Expand All @@ -86,6 +102,16 @@ def test_create_forwards_config_flags(self, runner: CliRunner) -> None:
"allow_future_responses": False,
}

def test_create_without_questions_is_rejected(self, runner: CliRunner) -> None:
# Questions are required at create time (server: questions_required).
with _auth(), _client() as cls:
client: MagicMock = cls.return_value
client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}]
result = runner.invoke(cli, ["checkin", "create", "-n", "Standup", "--team", "Eng"])
client.create_checkin.assert_not_called()
assert result.exit_code != 0
assert "at least one question" in result.output

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:
Expand All @@ -97,15 +123,26 @@ def test_create_without_participants_is_rejected(self, runner: CliRunner) -> Non
assert result.exit_code != 0
assert "at least one participant" in result.output

def test_create_resolves_participants(self, runner: CliRunner) -> None:
def test_create_resolves_participants(self, runner: CliRunner, qfile: str) -> 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"],
[
"checkin",
"create",
"-n",
"Standup",
"--user",
"Jane Doe",
"--team",
"Eng",
"--questions-file",
qfile,
],
)
assert result.exit_code == 0
participants = client.create_checkin.call_args[1]["participants"]
Expand Down
Loading