From 5b4e0b7679cee33c45206eab0747149df0af143a Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:13:42 +0000 Subject: [PATCH 01/35] feat(client): add report-channel + forms authoring methods - Task 1 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/api_client.py | 145 +++++++++++++++++++++++++++++++- tests/api_client_test.py | 167 +++++++++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+), 1 deletion(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index bfb0ffc..52929eb 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -449,11 +449,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,6 +548,131 @@ def delete_form_response( ) return self._handle_response(response) + # --- Report channels --- + + def list_report_channels(self) -> list[dict[str, Any]]: + """GET /v1/report-channels/ — reporting channels available to the caller.""" + 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) and "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, + ) -> dict[str, Any]: + """POST /v1/forms/create/ — create a form with optional inline questions.""" + payload: dict[str, Any] = {"name": name} + if questions: + payload["questions"] = questions + 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, + ) -> dict[str, Any]: + """PATCH /v1/forms//config/ — edit name and/or report channels.""" + payload: dict[str, Any] = {} + if name is not None: + payload["name"] = name + if report_channels is not None: + payload["report_channels"] = report_channels + 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={"order": order}, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + def list_users(self, *, include_inactive: bool = False) -> list[dict[str, Any]]: """GET /v1/users/ — fetch all pages and return the combined results list. diff --git a/tests/api_client_test.py b/tests/api_client_test.py index b413578..513f93e 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -817,3 +817,170 @@ 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_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_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"] == {"order": ["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 From 8aa8b757ab7c051c293075d62f8ee89946b15a2c Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:15:17 +0000 Subject: [PATCH 02/35] feat(client): add check-ins authoring methods - Task 2 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/api_client.py | 137 +++++++++++++++++++++++++++++++- tests/api_client_test.py | 158 +++++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 1 deletion(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 52929eb..1345fba 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -322,13 +322,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 +394,129 @@ 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, + ) -> dict[str, Any]: + """POST /v1/checkins/create/ — create a check-in with schedule + questions.""" + 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 + 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, + ) -> dict[str, Any]: + """PATCH /v1/checkins//config/ — edit name/schedule/channels/active.""" + 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 + 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={"order": 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] = {} diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 513f93e..f6c8cdd 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -984,3 +984,161 @@ def test_archive_form_raises_api_error_with_code(self, client: DailyBotClient) - 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_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_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"] == {"order": ["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 From e7b2f18a374a79577fbdc32999552a9f8fbdad75 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:18:21 +0000 Subject: [PATCH 03/35] feat(cli): add forms/check-ins authoring helpers and display - Task 3 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/authoring_helpers.py | 211 +++++++++++++++++++++ dailybot_cli/display.py | 102 ++++++++++ tests/authoring_helpers_test.py | 207 ++++++++++++++++++++ 3 files changed, 520 insertions(+) create mode 100644 dailybot_cli/commands/authoring_helpers.py create mode 100644 tests/authoring_helpers_test.py diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py new file mode 100644 index 0000000..4a80fd1 --- /dev/null +++ b/dailybot_cli/commands/authoring_helpers.py @@ -0,0 +1,211 @@ +"""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 +from pathlib import Path +from typing import Any + +import click + +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 +# 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}$") + + +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 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, +) -> 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. + """ + 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, + } + 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.") + return payload + + +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)) + questions.append(build_question(qtype, qtext, options=options, required=required)) + 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 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] = {} + if users: + directory: list[dict[str, Any]] = client.list_users() + 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 + ] + return participants diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index 745b31b..cce7449 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -839,3 +839,105 @@ def print_users_table(users: list[dict[str, Any]]) -> None: str(user.get("uuid") or ""), ) console.print(table) + + +def _question_rows(questions: list[dict[str, Any]]) -> Table: + """Build a questions table shared by created/list renderers.""" + 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("Required") + table.add_column("Question UUID", style="dim") + for index, question in enumerate(questions): + required_raw: Any = question.get("required") + if required_raw is None and "is_optional" in question: + required_raw = not question.get("is_optional") + required: str = "yes" if required_raw or required_raw is None else "no" + table.add_row( + str(question.get("index", index)), + str(question.get("question") or question.get("label") or question.get("text") or ""), + str(question.get("question_type") or question.get("type") or "text"), + required, + str(question.get("uuid") or question.get("id") or ""), + ) + 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]) -> None: + """Display a newly created form with its question summary.""" + name: str = str(form.get("name") or "") + form_id: str = str(form.get("id") or form.get("uuid") or "") + console.print( + Panel( + f"[bold]{name}[/bold]\nID: {form_id}", + title="[bold]Form Created[/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]) -> None: + """Display a newly created check-in with schedule and question 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}") + console.print( + Panel("\n".join(lines), title="[bold]Check-in Created[/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/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py new file mode 100644 index 0000000..12dd199 --- /dev/null +++ b/tests/authoring_helpers_test.py @@ -0,0 +1,207 @@ +"""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_question, + parse_options, + parse_participants, + parse_questions_file, + parse_schedule, +) + + +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, + } + + 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" + + +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_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 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() + + +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() From d0628a76747e86c40e1691d11f9b46e7f0662bc6 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:19:40 +0000 Subject: [PATCH 04/35] feat(cli): add channels list command - Task 4 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/channels.py | 49 ++++++++++++++++++ dailybot_cli/main.py | 2 + tests/channels_command_test.py | 83 +++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 dailybot_cli/commands/channels.py create mode 100644 tests/channels_command_test.py 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/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/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 From aaf4874790c332bc061d35ea90bebac03e7bfb09 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:23:58 +0000 Subject: [PATCH 05/35] feat(cli): add form authoring commands - Task 5 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/form.py | 389 +++++++++++++++++++++++++++++- tests/form_authoring_test.py | 233 ++++++++++++++++++ tests/public_api_commands_test.py | 18 +- 3 files changed, 632 insertions(+), 8 deletions(-) create mode 100644 tests/form_authoring_test.py diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 2ac7c8a..22274af 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -5,6 +5,11 @@ import click from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.commands.authoring_helpers import ( + build_question, + parse_options, + parse_questions_file, +) from dailybot_cli.commands.public_api_helpers import ( confirm_write, emit_json, @@ -19,11 +24,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, ) @@ -133,6 +143,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 +163,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 +258,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 +398,343 @@ 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( + "--report-channel", + "report_channels", + multiple=True, + help="Report-channel UUID (repeatable). See `dailybot channels list`.", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_create( + name: str, + questions_file: str | None, + report_channels: tuple[str, ...], + json_mode: bool, +) -> None: + """Create a form (optionally seeded with questions). + + \b + Creating forms is role-gated server-side (admins/managers as applicable). + Add questions inline with --questions-file, or later via + `dailybot form questions add`. + + \b + Examples: + dailybot form create --name "Sprint Retro" + dailybot form create -n "Retro" --questions-file questions.json + dailybot form create -n "Retro" --report-channel --json + """ + client = require_auth() + questions: list[dict[str, Any]] | None = ( + parse_questions_file(questions_file) if questions_file else None + ) + try: + with console.status("Creating form..."): + result: dict[str, Any] = client.create_form(name, questions) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + # Attach report channels in a follow-up config call when provided (create + # takes only name + questions). + if report_channels: + form_id: str = str(result.get("id") or result.get("uuid") or "") + try: + with console.status("Attaching report channels..."): + result = client.update_form_config(form_id, report_channels=list(report_channels)) + 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() + 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) + + +@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("--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, + json_mode: bool, +) -> None: + """Add a question to a form. + + \b + Examples: + dailybot form questions add --type text --question "What went well?" + dailybot form questions add --type multiple_choice \\ + --question "Rating?" --options "Excellent,Good,Average,Poor" + """ + client = require_auth() + payload: dict[str, Any] = build_question( + question_type, question, options=parse_options(options), required=required + ) + 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("--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, + json_mode: bool, +) -> None: + """Update a question's text, type, options, or required flag. + + \b + Examples: + dailybot form questions edit --question "Reworded?" + dailybot form questions edit --optional + """ + fields: dict[str, Any] = _build_question_edit_fields(question, question_type, options, required) + if not fields: + raise click.UsageError( + "Nothing to edit. Pass --question, --type, --options, or --required." + ) + 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) + + +def _build_question_edit_fields( + question: str | None, + question_type: str | None, + options: str | None, + required: bool | 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). + """ + 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 + return fields diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py new file mode 100644 index 0000000..a0cda41 --- /dev/null +++ b/tests/form_authoring_test.py @@ -0,0 +1,233 @@ +"""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?"}])) + 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_with_report_channel_calls_config(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.create_form.return_value = FORM_PAYLOAD + client.update_form_config.return_value = FORM_PAYLOAD + result = runner.invoke( + cli, ["form", "create", "-n", "Retro", "--report-channel", "chan-1"] + ) + assert result.exit_code == 0 + client.update_form_config.assert_called_once() + assert client.update_form_config.call_args[1]["report_channels"] == ["chan-1"] + + 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 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?"], + ) + assert result.exit_code == 0 + assert client.add_form_question.call_args[0][1]["question_type"] == "text" + + 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?", + ], + ) + 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"] + + +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/public_api_commands_test.py b/tests/public_api_commands_test.py index f3bf5ef..09fe3d7 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") From 5464066af5532ea4980c69cfe32c46eeb4cadda8 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:27:28 +0000 Subject: [PATCH 06/35] feat(cli): add check-in authoring commands - Task 6 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/authoring_helpers.py | 23 ++ dailybot_cli/commands/checkin.py | 346 ++++++++++++++++++++- dailybot_cli/commands/form.py | 26 +- tests/checkin_authoring_test.py | 167 ++++++++++ 4 files changed, 537 insertions(+), 25 deletions(-) create mode 100644 tests/checkin_authoring_test.py diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 4a80fd1..27a8e1a 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -85,6 +85,29 @@ def build_question( return payload +def build_question_edit_fields( + question: str | None, + question_type: str | None, + options: str | None, + required: bool | 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). + """ + 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 + return fields + + def parse_questions_file(path: str) -> list[dict[str, Any]]: """Load and validate a JSON array of question objects from ``path``. diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 5c15d2b..24b807e 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -1,10 +1,25 @@ """Check-in commands for the user-scoped public API.""" import sys +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 ( + build_question, + build_question_edit_fields, + parse_options, + parse_participants, + parse_questions_file, + parse_schedule, +) +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,6 +29,14 @@ 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." @@ -215,3 +238,324 @@ 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 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( + "--report-channel", + "report_channels", + multiple=True, + help="Report-channel UUID (repeatable). See `dailybot channels list`.", +) +@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, + report_channels: tuple[str, ...], + json_mode: bool, +) -> None: + """Create a check-in with a schedule, participants, and questions. + + \b + Creating check-ins is role-gated server-side (admins/managers). Add questions + via --questions-file, or later with `dailybot checkin questions add`. + + \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 + dailybot checkin create -n "Standup" --user "Jane Doe" --team "Eng" --json + """ + client = require_auth() + schedule: dict[str, Any] | None = parse_schedule( + days=days, time=time_, timezone=timezone, schedule_file=schedule_file + ) + participants: dict[str, Any] = parse_participants(users, teams, client) + questions: list[dict[str, Any]] | None = ( + parse_questions_file(questions_file) if questions_file else None + ) + 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, + ) + 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("--active/--inactive", "is_active", default=None, help="Activate or deactivate.") +@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, ...], + is_active: bool | None, + json_mode: bool, +) -> None: + """Edit a check-in's configuration (name, schedule, channels, active state). + + \b + Distinct from `checkin edit`, which edits your own response. This edits the + check-in definition (role-gated server-side). + + \b + Examples: + dailybot checkin config --time 10:00 --days 1,2,3,4,5 + dailybot checkin config --inactive + """ + schedule: dict[str, Any] | None = parse_schedule(days=days, time=time_, timezone=timezone) + if name is None and schedule is None and not report_channels and is_active is None: + raise click.UsageError( + "Nothing to edit. Pass --name, --time/--days/--timezone, " + "--report-channel, or --active/--inactive." + ) + client = require_auth() + 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, + ) + 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) + + +@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("--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, + json_mode: bool, +) -> None: + """Add a question to a check-in. + + \b + Examples: + dailybot checkin questions add --type text \\ + --question "What are you working on today?" + """ + client = require_auth() + payload: dict[str, Any] = build_question( + question_type, question, options=parse_options(options), required=required + ) + 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("--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, + json_mode: bool, +) -> None: + """Update a check-in question's text, type, options, or required flag. + + \b + Examples: + dailybot checkin questions edit \\ + --question "Do you need help today?" + """ + fields: dict[str, Any] = build_question_edit_fields(question, question_type, options, required) + if not fields: + raise click.UsageError( + "Nothing to edit. Pass --question, --type, --options, or --required." + ) + 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 22274af..2b67ac9 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -7,6 +7,7 @@ from dailybot_cli.api_client import APIError, DailyBotClient from dailybot_cli.commands.authoring_helpers import ( build_question, + build_question_edit_fields, parse_options, parse_questions_file, ) @@ -630,7 +631,7 @@ def form_questions_edit( dailybot form questions edit --question "Reworded?" dailybot form questions edit --optional """ - fields: dict[str, Any] = _build_question_edit_fields(question, question_type, options, required) + fields: dict[str, Any] = build_question_edit_fields(question, question_type, options, required) if not fields: raise click.UsageError( "Nothing to edit. Pass --question, --type, --options, or --required." @@ -715,26 +716,3 @@ def form_questions_reorder( emit_json({"reordered": True, "form_uuid": form_uuid, "order": order}) return print_reordered("form", order) - - -def _build_question_edit_fields( - question: str | None, - question_type: str | None, - options: str | None, - required: bool | 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). - """ - 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 - return fields diff --git a/tests/checkin_authoring_test.py b/tests/checkin_authoring_test.py new file mode 100644 index 0000000..e5eac7a --- /dev/null +++ b/tests/checkin_authoring_test.py @@ -0,0 +1,167 @@ +"""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.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 + result = runner.invoke( + cli, + [ + "checkin", + "create", + "-n", + "Standup", + "--time", + "09:00", + "--days", + "1,2,3,4,5", + "--timezone", + "UTC", + ], + ) + 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_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"} + + +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?", + ], + ) + assert result.exit_code == 0 + assert client.add_checkin_question.call_args[0][1]["question_type"] == "boolean" + + 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"] From db6dc417995270e79c639f6c4f333862cc0b7aeb Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:30:22 +0000 Subject: [PATCH 07/35] feat(cli): add interactive question builder - Task 7 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/authoring_helpers.py | 43 +++++++++ dailybot_cli/commands/checkin.py | 21 ++++- dailybot_cli/commands/form.py | 19 +++- tests/interactive_builder_test.py | 103 +++++++++++++++++++++ 4 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 tests/interactive_builder_test.py diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 27a8e1a..d28b4b5 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -6,10 +6,12 @@ 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 ( @@ -210,6 +212,47 @@ def parse_schedule( return _validate_schedule_dict(schedule) +def build_questions_interactively() -> 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. + """ + 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() + + try: + questions.append( + build_question(qtype, text or "", options=options, required=bool(required)) + ) + except AuthoringError as exc: + print_error(exc.message) + continue + + if not questionary.confirm("Add another question?", default=True).ask(): + break + return questions + + def parse_participants( users: tuple[str, ...], teams: tuple[str, ...], diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 24b807e..f4a59ae 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -9,6 +9,7 @@ from dailybot_cli.commands.authoring_helpers import ( build_question, build_question_edit_fields, + build_questions_interactively, parse_options, parse_participants, parse_questions_file, @@ -249,6 +250,11 @@ def checkin_edit( @click.option("--user", "users", multiple=True, help="Participant user (name 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", @@ -265,14 +271,16 @@ def checkin_create( users: tuple[str, ...], teams: tuple[str, ...], questions_file: str | None, + interactive: bool, report_channels: tuple[str, ...], json_mode: bool, ) -> None: """Create a check-in with a schedule, participants, and questions. \b - Creating check-ins is role-gated server-side (admins/managers). Add questions - via --questions-file, or later with `dailybot checkin questions add`. + 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`. \b Examples: @@ -285,9 +293,12 @@ def checkin_create( days=days, time=time_, timezone=timezone, schedule_file=schedule_file ) participants: dict[str, Any] = parse_participants(users, teams, client) - questions: list[dict[str, Any]] | None = ( - parse_questions_file(questions_file) if questions_file else None - ) + if interactive: + questions: list[dict[str, Any]] | None = build_questions_interactively() + elif questions_file: + questions = parse_questions_file(questions_file) + else: + questions = None try: with console.status("Creating check-in..."): result: dict[str, Any] = client.create_checkin( diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 2b67ac9..5bc8263 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -8,6 +8,7 @@ from dailybot_cli.commands.authoring_helpers import ( build_question, build_question_edit_fields, + build_questions_interactively, parse_options, parse_questions_file, ) @@ -408,6 +409,11 @@ def form_delete( 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", @@ -418,6 +424,7 @@ def form_delete( def form_create( name: str, questions_file: str | None, + interactive: bool, report_channels: tuple[str, ...], json_mode: bool, ) -> None: @@ -425,19 +432,23 @@ def form_create( \b Creating forms is role-gated server-side (admins/managers as applicable). - Add questions inline with --questions-file, or later via + Seed questions with --questions-file or --interactive, or add them later via `dailybot form questions add`. \b Examples: dailybot form create --name "Sprint Retro" dailybot form create -n "Retro" --questions-file questions.json + dailybot form create -n "Retro" --interactive dailybot form create -n "Retro" --report-channel --json """ client = require_auth() - questions: list[dict[str, Any]] | None = ( - parse_questions_file(questions_file) if questions_file else None - ) + if interactive: + questions: list[dict[str, Any]] | None = build_questions_interactively() + elif questions_file: + questions = parse_questions_file(questions_file) + else: + questions = None try: with console.status("Creating form..."): result: dict[str, Any] = client.create_form(name, questions) diff --git a/tests/interactive_builder_test.py b/tests/interactive_builder_test.py new file mode 100644 index 0000000..4f14d27 --- /dev/null +++ b/tests/interactive_builder_test.py @@ -0,0 +1,103 @@ +"""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")] + mock_q.text.side_effect = [ + _prompt("What went well?"), + _prompt("Rating?"), + _prompt("A, B, C"), + ] + mock_q.confirm.side_effect = [ + _prompt(True), # required? + _prompt(True), # add another? + _prompt(True), # required? + _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[1]["question_type"] == "multiple_choice" + assert result[1]["options"] == ["A", "B", "C"] + + @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"]) + 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": []} + + result = runner.invoke(cli, ["checkin", "create", "-n", "Standup", "--interactive"]) + assert result.exit_code == 0 + mock_builder.assert_called_once() + assert client.create_checkin.call_args[1]["questions"] == [ + {"question_type": "boolean", "question": "Blockers?"} + ] From 6c7f261c38b8ea41a27691c516e851ca78489768 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:32:09 +0000 Subject: [PATCH 08/35] fix(cli): harden authoring validation and error mapping - Task 8 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/public_api_helpers.py | 32 +++++ tests/authoring_security_test.py | 136 ++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 tests/authoring_security_test.py diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 7a94a41..47be40f 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -67,6 +67,38 @@ "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.", + "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.", + "checkin_not_found": "Check-in not found.", + "question_not_found": "Question not found on this form or check-in.", } diff --git a/tests/authoring_security_test.py b/tests/authoring_security_test.py new file mode 100644 index 0000000..9ec75f8 --- /dev/null +++ b/tests/authoring_security_test.py @@ -0,0 +1,136 @@ +"""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?", + ], + ) + 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 From 7bb74cf621c49221f6433e9fddeb56d4eb3acab4 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:33:41 +0000 Subject: [PATCH 09/35] test(cli): add authoring lifecycle integration tests - Task 9 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- tests/authoring_lifecycle_test.py | 181 ++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/authoring_lifecycle_test.py diff --git a/tests/authoring_lifecycle_test.py b/tests/authoring_lifecycle_test.py new file mode 100644 index 0000000..706ed82 --- /dev/null +++ b/tests/authoring_lifecycle_test.py @@ -0,0 +1,181 @@ +"""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?"}])) + 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?"], + ["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 = {} + + steps: list[list[str]] = [ + ["checkin", "create", "-n", "Standup", "--time", "09:00", "--days", "1,2,3"], + ["checkin", "questions", "add", "fu-1", "--type", "text", "--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" From ac03d08dd7753a7de48ea23c0eba63db96766869 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:37:01 +0000 Subject: [PATCH 10/35] docs: document forms/check-ins authoring commands - Task 10 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- README.md | 88 ++++++++++++++++++++++++++++++ docs/API_REFERENCE.md | 54 ++++++++++++++++-- docs/CLI_COMMAND_BEST_PRACTICES.md | 20 +++++++ 3 files changed, 157 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 991f072..258605d 100644 --- a/README.md +++ b/README.md @@ -205,8 +205,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 +255,88 @@ 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" +dailybot form questions edit --question "Reworded?" +dailybot form questions delete +dailybot form questions reorder + +# 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 --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`. +> `multiple_choice` needs `--options`; `boolean` auto-generates Yes/No (don't pass +> options). Up to 50 questions per form/check-in. + +### 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/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 255d315..c48b391 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/ [--state STATE] [--latest] [--json]` -Lists the caller's own responses (`GET /v1/forms//responses/`). `--state` filters by `current_state` (workflow forms only). `--latest` returns only the most recent — useful for "continue where I left off". +Lists responses (`GET /v1/forms//responses/`). Defaults to the caller's own. `--state` filters by `current_state` (workflow forms only). `--latest` returns only the most recent. `--all` / `--user ` 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 [--json]` @@ -183,7 +183,7 @@ Fetches a single response (`GET /v1/forms//responses//`) includ #### `dailybot form update --content JSON [--yes] [--json]` -Merges new answers into an in-progress response via `PATCH /v1/forms//responses//`. Strict own-only — admins are not elevated to other users' responses on this endpoint. +Merges new answers into a response via `PATCH /v1/forms//responses//`. 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 [--note ...] [--yes] [--json]` @@ -195,6 +195,35 @@ Deletes a response via `DELETE /v1/forms//responses//`. 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 create -n NAME [--questions-file F] [--interactive] [--report-channel UUID]` | `POST /v1/forms/create/` (+ `PATCH /config/` for channels) | Question types: `text`, `multiple_choice`, `boolean`, `numeric`; ≤ 50. | +| `form edit [--name] [--report-channel]` | `PATCH /v1/forms//config/` | | +| `form archive ` | `DELETE /v1/forms//archive/` | Soft-delete (204). Confirms unless `--yes`. | +| `form questions list ` | `GET /v1/forms//` | | +| `form questions add --type --question [--options] [--required/--optional]` | `POST /v1/forms//questions/` | `multiple_choice` requires `--options`; `boolean` takes none. | +| `form questions edit [...]` | `PATCH /v1/forms//questions//` | Partial update. | +| `form questions delete ` | `DELETE /v1/forms//questions//delete/` | Confirms unless `--yes`. | +| `form questions reorder ...` | `PUT /v1/forms//questions/reorder/` | | +| `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). | +| `checkin config [--name] [--time --days --timezone] [--report-channel] [--active/--inactive]` | `PATCH /v1/checkins//config/` | | +| `checkin archive ` | `DELETE /v1/checkins//archive/` | Soft-delete (204). Confirms unless `--yes`. | +| `checkin questions add/edit/delete/reorder` | `.../v1/checkins//questions/...` | Same shapes as form questions. | + +> **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 ] [--team ] --message [--value ] [--yes] [--json]` @@ -452,18 +481,33 @@ 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//` | — | `{ id, name, slug, workflow_enabled, workflow_config, questions: [...] }` | Used by guided form submit + `form get` | | `POST` | `/v1/forms//responses/` | `{ content: { "": "" } }` | `{ id, current_state?, allowed_transitions?, can_change_state? }` | 402 = quota exhausted | -| `GET` | `/v1/forms//responses/` | `?state=` (optional) | `[{ id, current_state, allowed_transitions, can_change_state, state_history, content, edited, created_at }]` | Caller's own responses | +| `GET` | `/v1/forms//responses/` | `?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//responses//` | — | Same shape as above | 404 = `form_response_not_found` | -| `PATCH` | `/v1/forms//responses//` | `{ content: { ... } }` | Updated response | Strict own-only | +| `PATCH` | `/v1/forms//responses//` | `{ content: { ... } }` | Updated response | Own always; owner/admin may edit anyone (audited) | | `POST` | `/v1/forms//responses//transition/` | `{ to_state, note? }` | Updated response | 403 = `form_response_change_state_forbidden` or `final_state_locked` | | `DELETE` | `/v1/forms//responses//` | — | 204 | Author / owner / admin | +| `GET` | `/v1/report-channels/` | — | `[{ uuid, name, platform, channel_id }]` | `channels list` | +| `POST` | `/v1/forms/create/` | `{ name, questions?: [...] }` | `{ id, name, is_active, questions }` | Role-gated; `form create` | +| `PATCH` | `/v1/forms//config/` | `{ name?, report_channels? }` | Form | `form edit` | +| `DELETE` | `/v1/forms//archive/` | — | 204 | `form archive` (soft-delete) | +| `POST` | `/v1/forms//questions/` | `{ question_type, question, options?, required? }` | Question | `form questions add` | +| `PATCH` | `/v1/forms//questions//` | partial | Question | `form questions edit` | +| `DELETE` | `/v1/forms//questions//delete/` | — | 204 | `form questions delete` | +| `PUT` | `/v1/forms//questions/reorder/` | `{ order: [q_uuid...] }` | `{ reordered: true }` | `form questions reorder` | | `POST` | `/v1/checkins//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//` | — | `{ ... }` | Check-in detail | | `GET` | `/v1/templates//` | `?render_special_vars=true&followup_id=` | `{ questions: [...] }` | Question definitions for a check-in | -| `GET` | `/v1/checkins//responses/` | `?date_start&date_end` | `[{ ... }]` | Today's response (edit/reset) | +| `GET` | `/v1/checkins//responses/` | `?date_start&date_end`, `?all=true`, `?user` | `[{ ... }]` | History; `all`/`user` admin/owner-only | | `PUT` | `/v1/checkins//responses/` | `{ responses: [...], last_question_index? }` | Updated response | `/checkin edit` | | `DELETE` | `/v1/checkins//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//config/` | `{ name?, schedule?, report_channels?, is_active? }` | Check-in | `checkin config` | +| `DELETE` | `/v1/checkins//archive/` | — | 204 | `checkin archive` (soft-delete) | +| `POST` | `/v1/checkins//questions/` | `{ question_type, question, options?, required? }` | Question | `checkin questions add` | +| `PATCH` | `/v1/checkins//questions//` | partial | Question | `checkin questions edit` | +| `DELETE` | `/v1/checkins//questions//delete/` | — | 204 | `checkin questions delete` | +| `PUT` | `/v1/checkins//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` | From c870893bbcb2ee8ea29c018aa0c2f97e2fd928e4 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:40:34 +0000 Subject: [PATCH 11/35] docs(skill): sync vendored dailybot skill with authoring - Task 11 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- .agents/docs/skills_agents_catalog.md | 7 ++- .agents/skills/dailybot/SKILL.md | 21 +++++-- .agents/skills/dailybot/channels/SKILL.md | 75 +++++++++++++++++++++++ .agents/skills/dailybot/checkin/SKILL.md | 39 ++++++++++++ .agents/skills/dailybot/forms/SKILL.md | 47 ++++++++++++++ 5 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 .agents/skills/dailybot/channels/SKILL.md 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..0a28c29 100644 --- a/.agents/skills/dailybot/checkin/SKILL.md +++ b/.agents/skills/dailybot/checkin/SKILL.md @@ -249,6 +249,45 @@ 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 / activate-deactivate / archive. Note: `checkin config` edits the +# definition; `checkin edit` still edits your *response*, `checkin reset` deletes it. +dailybot checkin config --time 10:00 --days 1,2,3,4,5 +dailybot checkin config --inactive +dailybot checkin archive + +# Manage questions (same shapes as form questions) +dailybot checkin questions add --type text --question "Focus today?" +dailybot checkin questions edit --question "Need help?" +dailybot checkin questions delete --yes +dailybot checkin questions reorder + +# 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": "..."}`). **Participants:** +repeatable `--user` / `--team` accept a name or a UUID. **Question types** match +forms: `text`, `multiple_choice` (needs `--options`), `boolean` (no options), +`numeric`; up to 50. + +--- + ## 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..5945ce7 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -515,6 +515,53 @@ 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 + +# 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 +dailybot form archive + +# Manage questions +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 edit --question "Reworded?" +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`. +`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?}` objects (`type`/`label` aliases +also accepted). + +--- + ## Step 12 — Multi-turn Lifecycle Examples ### Example A — Non-workflow form ("Team Feedback") From b734d226ae1ac069874ceb4cb7c26b4ac681aefe Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 15:42:17 +0000 Subject: [PATCH 12/35] chore(security): security review of authoring feature - Task 12 of PLAN_agent_forms_checkins_authoring Co-Authored-By: Claude Opus 4.8 --- docs/SECURITY.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ` 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 From 128a821db24989d9aaaef7757433baac5979c3bf Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 16:19:41 +0000 Subject: [PATCH 13/35] refactor(cli): attach report_channels inline on form create The finalized API contract confirms POST /v1/forms/create/ accepts report_channels in the body, so drop the follow-up PATCH /config/ round-trip. Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/api_client.py | 6 +++++- dailybot_cli/commands/form.py | 16 +++++----------- docs/API_REFERENCE.md | 4 ++-- tests/api_client_test.py | 13 +++++++++++++ tests/form_authoring_test.py | 8 ++++---- 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 1345fba..3069a48 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -707,11 +707,15 @@ def create_form( self, name: str, questions: list[dict[str, Any]] | None = None, + *, + report_channels: list[str] | None = None, ) -> dict[str, Any]: - """POST /v1/forms/create/ — create a form with optional inline questions.""" + """POST /v1/forms/create/ — create a form with optional questions + channels.""" payload: dict[str, Any] = {"name": name} if questions: payload["questions"] = questions + if report_channels: + payload["report_channels"] = report_channels response: httpx.Response = httpx.post( f"{self.api_url}/v1/forms/create/", json=payload, diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 5bc8263..1192774 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -451,20 +451,14 @@ def form_create( questions = None try: with console.status("Creating form..."): - result: dict[str, Any] = client.create_form(name, questions) + result: dict[str, Any] = client.create_form( + name, + questions, + report_channels=list(report_channels) if report_channels else None, + ) except APIError as exc: exit_for_api_error(exc, json_mode) - # Attach report channels in a follow-up config call when provided (create - # takes only name + questions). - if report_channels: - form_id: str = str(result.get("id") or result.get("uuid") or "") - try: - with console.status("Attaching report channels..."): - result = client.update_form_config(form_id, report_channels=list(report_channels)) - except APIError as exc: - exit_for_api_error(exc, json_mode) - if json_mode: emit_json(result) return diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c48b391..327b588 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -207,7 +207,7 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | Command | HTTP | Notes | |---|---|---| -| `form create -n NAME [--questions-file F] [--interactive] [--report-channel UUID]` | `POST /v1/forms/create/` (+ `PATCH /config/` for channels) | Question types: `text`, `multiple_choice`, `boolean`, `numeric`; ≤ 50. | +| `form create -n NAME [--questions-file F] [--interactive] [--report-channel UUID]` | `POST /v1/forms/create/` (`name`, `questions?`, `report_channels?` all inline) | Question types: `text`, `multiple_choice`, `boolean`, `numeric`; ≤ 50. | | `form edit [--name] [--report-channel]` | `PATCH /v1/forms//config/` | | | `form archive ` | `DELETE /v1/forms//archive/` | Soft-delete (204). Confirms unless `--yes`. | | `form questions list ` | `GET /v1/forms//` | | @@ -487,7 +487,7 @@ key, so all of these commands work with `DAILYBOT_API_KEY` set even without | `POST` | `/v1/forms//responses//transition/` | `{ to_state, note? }` | Updated response | 403 = `form_response_change_state_forbidden` or `final_state_locked` | | `DELETE` | `/v1/forms//responses//` | — | 204 | Author / owner / admin | | `GET` | `/v1/report-channels/` | — | `[{ uuid, name, platform, channel_id }]` | `channels list` | -| `POST` | `/v1/forms/create/` | `{ name, questions?: [...] }` | `{ id, name, is_active, questions }` | Role-gated; `form create` | +| `POST` | `/v1/forms/create/` | `{ name, questions?: [...], report_channels?: [...] }` | `{ id, name, is_active, questions }` | Role-gated; `form create` | | `PATCH` | `/v1/forms//config/` | `{ name?, report_channels? }` | Form | `form edit` | | `DELETE` | `/v1/forms//archive/` | — | 204 | `form archive` (soft-delete) | | `POST` | `/v1/forms//questions/` | `{ question_type, question, options?, required? }` | Question | `form questions add` | diff --git a/tests/api_client_test.py b/tests/api_client_test.py index f6c8cdd..3f41a50 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -870,6 +870,19 @@ def test_create_form_omits_empty_questions(self, client: DailyBotClient) -> None assert mock_post.call_args[1]["json"] == {"name": "Retro"} + 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 diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py index a0cda41..1ac2e5c 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -53,17 +53,17 @@ def test_create_with_questions_file(self, runner: CliRunner, tmp_path: Any) -> N sent_questions = client.create_form.call_args[0][1] assert sent_questions[0]["question_type"] == "text" - def test_create_with_report_channel_calls_config(self, runner: CliRunner) -> None: + 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 - client.update_form_config.return_value = FORM_PAYLOAD result = runner.invoke( cli, ["form", "create", "-n", "Retro", "--report-channel", "chan-1"] ) assert result.exit_code == 0 - client.update_form_config.assert_called_once() - assert client.update_form_config.call_args[1]["report_channels"] == ["chan-1"] + # 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_invalid_question_type_fails_fast( self, runner: CliRunner, tmp_path: Any From e8a13bb58ea458de452d557adb5c65afdb259eba Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 16:52:47 +0000 Subject: [PATCH 14/35] refactor(display): drop question-shape fallbacks now the API contract is canonical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API team confirmed every read endpoint (form detail, form list, and all authoring responses) returns the canonical question shape, so the type/label/is_optional/choices normalization in the shared render helper is no longer needed. print_checkin_detail is untouched — it reads the templates endpoint, which is a separate shape. Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/display.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index cce7449..8c29f2f 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -842,7 +842,12 @@ def print_users_table(users: list[dict[str, Any]]) -> None: def _question_rows(questions: list[dict[str, Any]]) -> Table: - """Build a questions table shared by created/list renderers.""" + """Build a questions table shared by the authoring renderers. + + Every authoring/read endpoint returns the canonical question shape + (``uuid`` / ``index`` / ``question`` / ``question_type`` / ``required`` / + ``choices``), so no field-name normalization is needed here. + """ table: Table = Table(title="Questions", border_style="cyan") table.add_column("#", justify="right") table.add_column("Question", style="bold") @@ -850,16 +855,12 @@ def _question_rows(questions: list[dict[str, Any]]) -> Table: table.add_column("Required") table.add_column("Question UUID", style="dim") for index, question in enumerate(questions): - required_raw: Any = question.get("required") - if required_raw is None and "is_optional" in question: - required_raw = not question.get("is_optional") - required: str = "yes" if required_raw or required_raw is None else "no" table.add_row( str(question.get("index", index)), - str(question.get("question") or question.get("label") or question.get("text") or ""), - str(question.get("question_type") or question.get("type") or "text"), - required, - str(question.get("uuid") or question.get("id") or ""), + str(question.get("question", "")), + str(question.get("question_type", "")), + "yes" if question.get("required", True) else "no", + str(question.get("uuid", "")), ) return table From 13d4a156bb48a8417408a51f55e0682a7a39eeec Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 19:50:50 +0000 Subject: [PATCH 15/35] feat(release): support minimum-version floor in installers ## Summary Let users pin a minimum version (not just an exact one) when installing via install.sh, install.ps1, or pip. `>=1.15.0` installs the newest release at or above the floor; the existing exact-pin forms (`1.15.0`, `==1.15.0`) are unchanged. ## Change Log - install.sh: split an optional `==`/`>=` operator from the numeric token; validate only the number against the existing charset; reject unsupported operators (<, >, <=, ~=, !=) up front. For `>=`, resolve the latest release tag and compare with `sort -V`, downloading the binary only when it satisfies the floor, otherwise deferring to `pip install "dailybot-cli>="`. - install.ps1: same operator parsing; emit `dailybot-cli>=` so pip resolves the floor natively (pip-only path, no binary resolution needed). - Regenerated install.sh.sha256 / install.ps1.sha256 sidecars. - README + RELEASE_AND_DISTRIBUTION: document exact vs. minimum-floor pinning for every install method, plus the specifier-parsing/security note. ## Risks - Backwards compatible: empty = latest, bare/`==` = exact (unchanged). New behavior is additive behind the `>=` prefix. - Injection-safe: whitespace stripped then the number is charset-validated before reaching any pip/URL command; verified `1.0.0; rm -rf /` aborts. Co-Authored-By: Claude Opus 4.8 --- README.md | 39 ++++++++++---- docs/RELEASE_AND_DISTRIBUTION.md | 12 ++--- install.ps1 | 50 ++++++++++++++---- install.ps1.sha256 | 2 +- install.sh | 88 +++++++++++++++++++++++++------- install.sh.sha256 | 2 +- 6 files changed, 148 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 258605d..3f7059f 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`. 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==` | pip requirement specifier | -| Homebrew | not supported — fall back to `pip install dailybot-cli==` | (brew installs latest formula only) | -| `install.sh` | `DAILYBOT_VERSION=` env var **or** `bash -s -- --version ` | pinned Linux binary tag `v`, falling back to `pip install dailybot-cli==` | -| `install.ps1` | `$env:DAILYBOT_VERSION = ''` (piping to `iex` cannot forward args) | `pipx` / `uv tool` / `pip --user` install of `dailybot-cli==` | +| `pip` | `pip install dailybot-cli==` or `pip install "dailybot-cli>="` | pip requirement specifier (pip natively resolves `>=` to the newest match) | +| Homebrew | not supported — fall back to `pip install dailybot-cli==` / `>=` | (brew installs latest formula only) | +| `install.sh` | `DAILYBOT_VERSION=` env var **or** `bash -s -- --version `, where `` is ``, `==` or `>=` | **exact** → pinned Linux binary tag `v`, falling back to `pip install dailybot-cli==`; **floor** → latest binary when it satisfies the floor, else `pip install "dailybot-cli>="` | +| `install.ps1` | `$env:DAILYBOT_VERSION = ''` (piping to `iex` cannot forward args) | `pipx` / `uv tool` / `pip --user` install of `dailybot-cli==` or `dailybot-cli>=` | -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/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==" when a version is pinned. - if ($Version) { return "$Package==$Version" } + # "dailybot-cli", "dailybot-cli==" (exact pin) or + # "dailybot-cli>=" (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==". +# 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==" or +# "dailybot-cli>=". 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 From 235ad93b9392f753808c5e2a763b56fe2cd4fc95 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 20:59:16 +0000 Subject: [PATCH 16/35] feat(forms): align authoring surface to the finalized canonical contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The API team finalized the forms & check-ins authoring contract (docs/PUBLIC_API/AUTHORING_CONTRACT.md). This aligns the CLI to it: canonical question shape on every read path, is_blocker authoring, resolved participants, archive/list filtering, and the new canonical check-in detail endpoint. ## Change Log - api_client: add get_checkin_detail (GET /v1/checkins//detail/); thread include_archived into list_forms and list_checkins. - authoring_helpers: thread is_blocker through build_question, build_question_edit_fields, parse_questions_file, and the interactive builder. - display: render {label,value} choices + a Blocker column in the shared question table; rewrite print_checkin_detail for the canonical /detail/ shape (resolved participants, attached report channels, is_archived); add a Status column to the forms list; enrich form detail; fix Created-vs-Updated panel titles on edit/config. - commands: --include-archived on `form list`; --blocker/--no-blocker on form/checkin question add+edit; migrate `checkin show` to get_checkin_detail; panel says "updated" on form edit / checkin config. - BREAKING (server): choices are now [{label,value}] on all read paths — the shared renderer reads .label; form-submit/TUI prompt paths already tolerated this. reorder now 400s on unknown UUIDs (server-side; no client change). - tests: +18 (choices/blocker rendering, get_checkin_detail, include_archived, is_blocker authoring, participants, archived status); updated fixtures for the new shapes. - docs: API_REFERENCE + vendored forms/checkin SKILL packs. ## Risks - Additive on read (required/index/is_blocker/participants/report_channels are new keys). The one breaking change (choices shape) is handled by a single canonical renderer; no other read site indexes choices as strings. - checkin show now depends on the /detail/ endpoint — validate against a backend carrying the finalized contract before release. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/dailybot/checkin/SKILL.md | 16 +- .agents/skills/dailybot/forms/SKILL.md | 19 ++- dailybot_cli/api_client.py | 30 +++- dailybot_cli/commands/authoring_helpers.py | 25 ++- dailybot_cli/commands/checkin.py | 33 +++- dailybot_cli/commands/form.py | 42 ++++- dailybot_cli/commands/user_scoped_actions.py | 38 ++--- dailybot_cli/display.py | 162 +++++++++++++------ docs/API_REFERENCE.md | 28 ++-- tests/api_client_test.py | 36 +++++ tests/authoring_helpers_test.py | 106 ++++++++++++ tests/checkin_authoring_test.py | 29 ++++ tests/form_authoring_test.py | 41 +++++ tests/interactive_builder_test.py | 7 +- tests/public_api_commands_test.py | 26 ++- 15 files changed, 522 insertions(+), 116 deletions(-) diff --git a/.agents/skills/dailybot/checkin/SKILL.md b/.agents/skills/dailybot/checkin/SKILL.md index 0a28c29..501a183 100644 --- a/.agents/skills/dailybot/checkin/SKILL.md +++ b/.agents/skills/dailybot/checkin/SKILL.md @@ -269,12 +269,18 @@ dailybot checkin config --time 10:00 --days 1,2,3,4,5 dailybot checkin config --inactive dailybot checkin archive -# Manage questions (same shapes as form questions) +# 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 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 ``` @@ -284,7 +290,13 @@ is `HH:MM`; `--timezone` is an IANA name. Or pass `--schedule-file` (`{"days": [...], "time": "HH:MM", "timezone": "..."}`). **Participants:** repeatable `--user` / `--team` accept a name or a UUID. **Question types** match forms: `text`, `multiple_choice` (needs `--options`), `boolean` (no options), -`numeric`; up to 50. +`numeric`; up to 50. Tag the blocker question with `--blocker`. + +**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. --- diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index 5945ce7..18fa1a6 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -531,17 +531,23 @@ 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 dailybot form archive -# Manage questions +# 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 dailybot form questions edit --question "Reworded?" +dailybot form questions edit --blocker dailybot form questions delete --yes dailybot form questions reorder @@ -557,8 +563,15 @@ dailybot form update --content '{"":"correct **Question types:** `text`, `multiple_choice`, `boolean`, `numeric`. `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?}` objects (`type`/`label` aliases -also accepted). +`{question_type, question, options?, required?, is_blocker?}` objects +(`type`/`label` aliases also accepted). Any question may be tagged the **blocker** +question with `--blocker` (or `"is_blocker": true` in the file). + +**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 `[]`. --- diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 3069a48..2198f22 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, @@ -543,9 +561,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(), diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index d28b4b5..8827796 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -55,11 +55,13 @@ def build_question( *, options: list[str] | None = None, required: bool = True, + is_blocker: bool = False, ) -> 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. + 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). """ qtype: str = question_type.strip().lower() if qtype not in VALID_QUESTION_TYPES: @@ -75,6 +77,7 @@ def build_question( "question_type": qtype, "question": text, "required": required, + "is_blocker": is_blocker, } if qtype == "multiple_choice": if not options: @@ -92,11 +95,13 @@ def build_question_edit_fields( question_type: str | None, options: str | None, required: bool | None, + is_blocker: bool | 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). """ fields: dict[str, Any] = {} if question is not None: @@ -107,6 +112,8 @@ def build_question_edit_fields( 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 return fields @@ -141,7 +148,10 @@ def parse_questions_file(path: str) -> list[dict[str, Any]]: [str(option) for option in raw_options] if isinstance(raw_options, list) else None ) required: bool = bool(item.get("required", True)) - questions.append(build_question(qtype, qtext, options=options, required=required)) + is_blocker: bool = bool(item.get("is_blocker", False)) + questions.append( + build_question(qtype, qtext, options=options, required=required, is_blocker=is_blocker) + ) return questions @@ -239,10 +249,19 @@ def build_questions_interactively() -> list[dict[str, Any]]: 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() try: questions.append( - build_question(qtype, text or "", options=options, required=bool(required)) + build_question( + qtype, + text or "", + options=options, + required=bool(required), + is_blocker=bool(is_blocker), + ) ) except AuthoringError as exc: print_error(exc.message) diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index f4a59ae..e8daa84 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -375,7 +375,7 @@ def checkin_config( emit_json(result) return print_success(f"Check-in {followup_uuid} updated.") - print_checkin_created(result) + print_checkin_created(result, updated=True) @checkin.command("archive") @@ -427,6 +427,12 @@ def checkin_questions() -> None: @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.", +) @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def checkin_questions_add( followup_uuid: str, @@ -434,6 +440,7 @@ def checkin_questions_add( question: str, options: str | None, required: bool, + is_blocker: bool, json_mode: bool, ) -> None: """Add a question to a check-in. @@ -442,10 +449,16 @@ def checkin_questions_add( Examples: dailybot checkin questions add --type text \\ --question "What are you working on today?" + dailybot checkin questions add --type boolean \\ + --question "Any blockers?" --blocker """ client = require_auth() payload: dict[str, Any] = build_question( - question_type, question, options=parse_options(options), required=required + question_type, + question, + options=parse_options(options), + required=required, + is_blocker=is_blocker, ) try: with console.status("Adding question..."): @@ -467,6 +480,12 @@ def checkin_questions_add( @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.", +) @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def checkin_questions_edit( followup_uuid: str, @@ -475,19 +494,23 @@ def checkin_questions_edit( question_type: str | None, options: str | None, required: bool | None, + is_blocker: bool | None, json_mode: bool, ) -> None: - """Update a check-in question's text, type, options, or required flag. + """Update a check-in question's text, type, options, required, or blocker flag. \b Examples: dailybot checkin questions edit \\ --question "Do you need help today?" + dailybot checkin questions edit --blocker """ - fields: dict[str, Any] = build_question_edit_fields(question, question_type, options, required) + fields: dict[str, Any] = build_question_edit_fields( + question, question_type, options, required, is_blocker + ) if not fields: raise click.UsageError( - "Nothing to edit. Pass --question, --type, --options, or --required." + "Nothing to edit. Pass --question, --type, --options, --required, or --blocker." ) client = require_auth() try: diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 1192774..543676d 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -58,20 +58,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") @@ -505,7 +512,7 @@ def form_edit( emit_json(result) return print_success(f"Form {form_uuid} updated.") - print_form_created(result) + print_form_created(result, updated=True) @form.command("archive") @@ -578,6 +585,12 @@ def form_questions_list(form_uuid: str, json_mode: bool) -> None: @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.", +) @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def form_questions_add( form_uuid: str, @@ -585,6 +598,7 @@ def form_questions_add( question: str, options: str | None, required: bool, + is_blocker: bool, json_mode: bool, ) -> None: """Add a question to a form. @@ -597,7 +611,11 @@ def form_questions_add( """ client = require_auth() payload: dict[str, Any] = build_question( - question_type, question, options=parse_options(options), required=required + question_type, + question, + options=parse_options(options), + required=required, + is_blocker=is_blocker, ) try: with console.status("Adding question..."): @@ -619,6 +637,12 @@ def form_questions_add( @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.", +) @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def form_questions_edit( form_uuid: str, @@ -627,19 +651,23 @@ def form_questions_edit( question_type: str | None, options: str | None, required: bool | None, + is_blocker: bool | None, json_mode: bool, ) -> None: - """Update a question's text, type, options, or required flag. + """Update a question's text, type, options, required, or blocker flag. \b Examples: dailybot form questions edit --question "Reworded?" dailybot form questions edit --optional + dailybot form questions edit --blocker """ - fields: dict[str, Any] = build_question_edit_fields(question, question_type, options, required) + fields: dict[str, Any] = build_question_edit_fields( + question, question_type, options, required, is_blocker + ) if not fields: raise click.UsageError( - "Nothing to edit. Pass --question, --type, --options, or --required." + "Nothing to edit. Pass --question, --type, --options, --required, or --blocker." ) client = require_auth() try: 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 8c29f2f..33e4ba2 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -491,31 +491,69 @@ 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 ({id, type, reporting_enabled}).""" + if not channels: + return + table: Table = Table(title="Report Channels", border_style="cyan") + table.add_column("Channel ID", style="bold") + table.add_column("Type") + table.add_column("Reporting") + for channel in channels: + table.add_row( + str(channel.get("id") or channel.get("uuid") or ""), + str(channel.get("type") or ""), + "on" if channel.get("reporting_enabled", True) else "off", + ) + console.print(table) + + +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}") 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 +584,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 +599,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) @@ -606,8 +650,12 @@ def print_form_detail(form_data: dict[str, Any]) -> None: "Reopen from final", "yes" if form_data.get("allow_reopen_from_final_state") else "no", ) + if form_data.get("is_archived"): + table.add_row("Status", "[yellow]archived[/yellow]") console.print(Panel(table, title="[bold]Form[/bold]", border_style="cyan")) + _print_attached_channels(form_data.get("report_channels") or []) + if workflow_enabled: states_table: Table = Table(title="Workflow States", border_style="cyan") states_table.add_column("Order", style="dim", justify="right") @@ -629,19 +677,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( @@ -841,25 +877,55 @@ def print_users_table(users: list[dict[str, Any]]) -> None: 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`` / - ``choices``), so no field-name normalization is needed here. + ``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", style="bold") + 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") + blocker_cell: Text = ( + Text("yes", style="red") if question.get("is_blocker") else Text("—", style="dim") + ) table.add_row( str(question.get("index", index)), - str(question.get("question", "")), + question_cell, str(question.get("question_type", "")), "yes" if question.get("required", True) else "no", + blocker_cell, str(question.get("uuid", "")), ) return table @@ -883,14 +949,15 @@ def print_report_channels(channels: list[dict[str, Any]]) -> None: console.print(table) -def print_form_created(form: dict[str, Any]) -> None: - """Display a newly created form with its question summary.""" +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" console.print( Panel( f"[bold]{name}[/bold]\nID: {form_id}", - title="[bold]Form Created[/bold]", + title=f"[bold]{title}[/bold]", border_style="green", ) ) @@ -899,8 +966,8 @@ def print_form_created(form: dict[str, Any]) -> None: console.print(_question_rows(questions)) -def print_checkin_created(checkin: dict[str, Any]) -> None: - """Display a newly created check-in with schedule and question summary.""" +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}"] @@ -913,9 +980,8 @@ def print_checkin_created(checkin: dict[str, Any]) -> None: value: Any = schedule.get(key) if value: lines.append(f"{label}: {value}") - console.print( - Panel("\n".join(lines), title="[bold]Check-in Created[/bold]", border_style="green") - ) + 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)) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 327b588..729a744 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -207,18 +207,22 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | 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]` | `POST /v1/forms/create/` (`name`, `questions?`, `report_channels?` all inline) | Question types: `text`, `multiple_choice`, `boolean`, `numeric`; ≤ 50. | | `form edit [--name] [--report-channel]` | `PATCH /v1/forms//config/` | | | `form archive ` | `DELETE /v1/forms//archive/` | Soft-delete (204). Confirms unless `--yes`. | -| `form questions list ` | `GET /v1/forms//` | | -| `form questions add --type --question [--options] [--required/--optional]` | `POST /v1/forms//questions/` | `multiple_choice` requires `--options`; `boolean` takes none. | -| `form questions edit [...]` | `PATCH /v1/forms//questions//` | Partial update. | +| `form questions list ` | `GET /v1/forms//` | Canonical question shape (see below). | +| `form questions add --type --question [--options] [--required/--optional] [--blocker]` | `POST /v1/forms//questions/` | `multiple_choice` requires `--options`; `boolean` takes none. `--blocker` tags the blocker question. | +| `form questions edit [... --blocker/--no-blocker]` | `PATCH /v1/forms//questions//` | Partial update (non-destructive). | | `form questions delete ` | `DELETE /v1/forms//questions//delete/` | Confirms unless `--yes`. | -| `form questions reorder ...` | `PUT /v1/forms//questions/reorder/` | | +| `form questions reorder ...` | `PUT /v1/forms//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). | +| `checkin show ` | `GET /v1/checkins//detail/` | Canonical detail: schedule, resolved `participants`, attached `report_channels`, canonical questions. | | `checkin config [--name] [--time --days --timezone] [--report-channel] [--active/--inactive]` | `PATCH /v1/checkins//config/` | | | `checkin archive ` | `DELETE /v1/checkins//archive/` | Soft-delete (204). Confirms unless `--yes`. | -| `checkin questions add/edit/delete/reorder` | `.../v1/checkins//questions/...` | Same shapes as form questions. | +| `checkin questions add/edit/delete/reorder` | `.../v1/checkins//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. @@ -487,24 +491,26 @@ key, so all of these commands work with `DAILYBOT_API_KEY` set even without | `POST` | `/v1/forms//responses//transition/` | `{ to_state, note? }` | Updated response | 403 = `form_response_change_state_forbidden` or `final_state_locked` | | `DELETE` | `/v1/forms//responses//` | — | 204 | Author / owner / admin | | `GET` | `/v1/report-channels/` | — | `[{ uuid, name, platform, channel_id }]` | `channels list` | -| `POST` | `/v1/forms/create/` | `{ name, questions?: [...], report_channels?: [...] }` | `{ id, name, is_active, questions }` | Role-gated; `form create` | +| `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//config/` | `{ name?, report_channels? }` | Form | `form edit` | -| `DELETE` | `/v1/forms//archive/` | — | 204 | `form archive` (soft-delete) | -| `POST` | `/v1/forms//questions/` | `{ question_type, question, options?, required? }` | Question | `form questions add` | +| `DELETE` | `/v1/forms//archive/` | — | 204 (sets `is_active=false` + `is_archived=true`) | `form archive` (soft-delete) | +| `POST` | `/v1/forms//questions/` | `{ question_type, question, options?, required?, is_blocker? }` | Question | `form questions add` | | `PATCH` | `/v1/forms//questions//` | partial | Question | `form questions edit` | | `DELETE` | `/v1/forms//questions//delete/` | — | 204 | `form questions delete` | | `PUT` | `/v1/forms//questions/reorder/` | `{ order: [q_uuid...] }` | `{ reordered: true }` | `form questions reorder` | | `POST` | `/v1/checkins//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//` | — | `{ ... }` | Check-in detail | +| `GET` | `/v1/checkins//` | — | `{ ... }` | v2 retrieve serializer (different shape) | +| `GET` | `/v1/checkins//detail/` | — | `{ id, name, is_archived, schedule, questions, participants: {users,teams}, report_channels }` | **Canonical** authoring read; `checkin show` | | `GET` | `/v1/templates//` | `?render_special_vars=true&followup_id=` | `{ questions: [...] }` | Question definitions for a check-in | | `GET` | `/v1/checkins//responses/` | `?date_start&date_end`, `?all=true`, `?user` | `[{ ... }]` | History; `all`/`user` admin/owner-only | | `PUT` | `/v1/checkins//responses/` | `{ responses: [...], last_question_index? }` | Updated response | `/checkin edit` | | `DELETE` | `/v1/checkins//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//config/` | `{ name?, schedule?, report_channels?, is_active? }` | Check-in | `checkin config` | -| `DELETE` | `/v1/checkins//archive/` | — | 204 | `checkin archive` (soft-delete) | -| `POST` | `/v1/checkins//questions/` | `{ question_type, question, options?, required? }` | Question | `checkin questions add` | +| `DELETE` | `/v1/checkins//archive/` | — | 204 (sets `active=false` + `archived=true`) | `checkin archive` (soft-delete) | +| `POST` | `/v1/checkins//questions/` | `{ question_type, question, options?, required?, is_blocker? }` | Question | `checkin questions add` | | `PATCH` | `/v1/checkins//questions//` | partial | Question | `checkin questions edit` | | `DELETE` | `/v1/checkins//questions//delete/` | — | 204 | `checkin questions delete` | | `PUT` | `/v1/checkins//questions/reorder/` | `{ order: [q_uuid...] }` | `{ reordered: true }` | `checkin questions reorder` | diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 3f41a50..1b098bb 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 @@ -804,6 +830,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 diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index 12dd199..c78c055 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -12,6 +12,7 @@ MAX_QUESTIONS, AuthoringError, build_question, + build_question_edit_fields, parse_options, parse_participants, parse_questions_file, @@ -26,6 +27,7 @@ def test_text_question(self) -> None: "question_type": "text", "question": "What went well?", "required": True, + "is_blocker": False, } def test_multiple_choice_with_options(self) -> None: @@ -54,6 +56,25 @@ def test_blank_text_rejected(self) -> None: 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 TestParseOptions: def test_splits_and_trims(self) -> None: @@ -82,6 +103,12 @@ def test_valid_file(self, tmp_path: Path) -> None: 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_missing_file_rejected(self, tmp_path: Path) -> None: with pytest.raises(AuthoringError): parse_questions_file(str(tmp_path / "nope.json")) @@ -205,3 +232,82 @@ 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 + 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", "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 + + 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 diff --git a/tests/checkin_authoring_test.py b/tests/checkin_authoring_test.py index e5eac7a..827c39d 100644 --- a/tests/checkin_authoring_test.py +++ b/tests/checkin_authoring_test.py @@ -137,6 +137,35 @@ def test_add(self, runner: CliRunner) -> None: assert result.exit_code == 0 assert client.add_checkin_question.call_args[0][1]["question_type"] == "boolean" + 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", + ], + ) + assert result.exit_code == 0 + assert client.add_checkin_question.call_args[0][1]["is_blocker"] is True + + 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 diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py index 1ac2e5c..7dba2ca 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -138,6 +138,27 @@ def test_add_text(self, runner: CliRunner) -> None: assert result.exit_code == 0 assert client.add_form_question.call_args[0][1]["question_type"] == "text" + 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", + ], + ) + 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( @@ -193,6 +214,26 @@ def test_reorder(self, runner: CliRunner) -> None: assert client.reorder_form_questions.call_args[0][1] == ["q2", "q1"] +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: diff --git a/tests/interactive_builder_test.py b/tests/interactive_builder_test.py index 4f14d27..ad259d6 100644 --- a/tests/interactive_builder_test.py +++ b/tests/interactive_builder_test.py @@ -37,15 +37,18 @@ def test_builds_two_questions(self, mock_q: MagicMock, _isatty: MagicMock) -> No _prompt("A, B, C"), ] mock_q.confirm.side_effect = [ - _prompt(True), # required? + _prompt(True), # Q1 required? + _prompt(False), # Q1 blocker? _prompt(True), # add another? - _prompt(True), # required? + _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[1]["question_type"] == "multiple_choice" assert result[1]["options"] == ["A", "B", "C"] diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index 09fe3d7..121e337 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -1146,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") From 0b0d20f083566a12aaee086a79e312a4eb9172f8 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sat, 4 Jul 2026 21:12:14 +0000 Subject: [PATCH 17/35] fix(client): unwrap {"channels": [...]} from the report-channels endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `channels list` returned "No report channels available" even for orgs with Slack channels configured. The `/v1/report-channels/` endpoint returns `{"channels": [...], "total": N}`, but the client only unwrapped a bare list or `{"results": [...]}` and silently fell through to `[]`. ## Change Log - api_client.list_report_channels: also unwrap the `channels` key (keep the `results` and bare-list paths for compatibility). - Added a regression test for the `{channels, total}` shape. - Corrected the docs/API_REFERENCE response shape ({channels:[{id,name,platform, type}], total}); `id` is what feeds --report-channel. ## Risks - None. Purely additive unwrapping; verified live against an org with 8 Slack channels — `channels list` now lists them and `--report-channel` round-trips into `checkin show`. Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/api_client.py | 14 +++++++++++--- docs/API_REFERENCE.md | 2 +- tests/api_client_test.py | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 2198f22..d98cb06 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -710,7 +710,12 @@ def delete_form_response( # --- Report channels --- def list_report_channels(self) -> list[dict[str, Any]]: - """GET /v1/report-channels/ — reporting channels available to the caller.""" + """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(), @@ -719,8 +724,11 @@ def list_report_channels(self) -> list[dict[str, Any]]: if response.status_code >= 400: self._handle_response(response) body: Any = response.json() - if isinstance(body, dict) and "results" in body: - return list(body.get("results", [])) + if isinstance(body, 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 [] diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 729a744..39f7d40 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -490,7 +490,7 @@ key, so all of these commands work with `DAILYBOT_API_KEY` set even without | `PATCH` | `/v1/forms//responses//` | `{ content: { ... } }` | Updated response | Own always; owner/admin may edit anyone (audited) | | `POST` | `/v1/forms//responses//transition/` | `{ to_state, note? }` | Updated response | 403 = `form_response_change_state_forbidden` or `final_state_locked` | | `DELETE` | `/v1/forms//responses//` | — | 204 | Author / owner / admin | -| `GET` | `/v1/report-channels/` | — | `[{ uuid, name, platform, channel_id }]` | `channels list` | +| `GET` | `/v1/report-channels/` | — | `{ 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//config/` | `{ name?, report_channels? }` | Form | `form edit` | diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 1b098bb..8f0753b 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -880,6 +880,24 @@ def test_list_report_channels_unwraps_results(self, client: DailyBotClient) -> N 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 From 3a766745670afd8a7be48415df9e05b7be11753f Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 01:16:06 +0000 Subject: [PATCH 18/35] feat(display): render report-channel names + map report_channel_not_found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the CLI to the API team's report-channel hardening (commit 9324837): channel validation on create/edit, names resolved on detail, and the documented channels endpoint. ## Change Log - display._print_attached_channels: render the resolved channel name as `#general` with platform, falling back to the raw id for legacy (unnamed) entries. Adds Channel + Platform columns to form/check-in detail. - public_api_helpers: map the new `report_channel_not_found` code to a friendly message pointing at `dailybot channels list`. - docs/API_REFERENCE: report_channels detail shape now carries name/platform; documented the `?name`/`?limit` query params on the channels endpoint. - tests: +3 (name renders as #general, id fallback when unnamed, friendly error on a bogus channel). ## Verified live - Bogus `--report-channel` → 400 report_channel_not_found → friendly CLI message. - Valid channel → `form get` renders "#general (slack)"; name round-trips from the create response through detail. Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/public_api_helpers.py | 4 ++++ dailybot_cli/display.py | 20 ++++++++++++----- docs/API_REFERENCE.md | 4 ++-- tests/authoring_helpers_test.py | 24 ++++++++++++++++++++- tests/form_authoring_test.py | 14 ++++++++++++ 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 47be40f..295a17b 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -99,6 +99,10 @@ "form_not_found": "Form not found.", "checkin_not_found": "Check-in not found.", "question_not_found": "Question not found on this form or check-in.", + "report_channel_not_found": ( + "Report channel not found — run `dailybot channels list` to see the " + "channels available in your organization." + ), } diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index 33e4ba2..d974611 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -509,17 +509,27 @@ def _print_participants(participants: dict[str, Any]) -> None: def _print_attached_channels(channels: list[dict[str, Any]]) -> None: - """Render report channels attached to a form/check-in ({id, type, reporting_enabled}).""" + """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 ID", style="bold") - table.add_column("Type") + 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( - str(channel.get("id") or channel.get("uuid") or ""), - str(channel.get("type") or ""), + display_name, + str(channel.get("platform") or ""), + channel_id, "on" if channel.get("reporting_enabled", True) else "off", ) console.print(table) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 39f7d40..939d308 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -490,7 +490,7 @@ key, so all of these commands work with `DAILYBOT_API_KEY` set even without | `PATCH` | `/v1/forms//responses//` | `{ content: { ... } }` | Updated response | Own always; owner/admin may edit anyone (audited) | | `POST` | `/v1/forms//responses//transition/` | `{ to_state, note? }` | Updated response | 403 = `form_response_change_state_forbidden` or `final_state_locked` | | `DELETE` | `/v1/forms//responses//` | — | 204 | Author / owner / admin | -| `GET` | `/v1/report-channels/` | — | `{ channels: [{ id, name, platform, type }], total }` (also accepts `{results}` / bare list) | `channels list`; `id` feeds `--report-channel` | +| `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//config/` | `{ name?, report_channels? }` | Form | `form edit` | @@ -502,7 +502,7 @@ key, so all of these commands work with `DAILYBOT_API_KEY` set even without | `POST` | `/v1/checkins//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//` | — | `{ ... }` | v2 retrieve serializer (different shape) | -| `GET` | `/v1/checkins//detail/` | — | `{ id, name, is_archived, schedule, questions, participants: {users,teams}, report_channels }` | **Canonical** authoring read; `checkin show` | +| `GET` | `/v1/checkins//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//` | `?render_special_vars=true&followup_id=` | `{ questions: [...] }` | Question definitions for a check-in | | `GET` | `/v1/checkins//responses/` | `?date_start&date_end`, `?all=true`, `?user` | `[{ ... }]` | History; `all`/`user` admin/owner-only | | `PUT` | `/v1/checkins//responses/` | `{ responses: [...], last_question_index? }` | Updated response | `/checkin edit` | diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index c78c055..38d9694 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -282,7 +282,15 @@ def test_checkin_detail_renders_participants(self) -> None: "users": [{"uuid": "u1", "name": "Jane Doe"}], "teams": [{"uuid": "t1", "name": "Engineering"}], }, - "report_channels": [{"id": "C1", "type": "channel", "reporting_enabled": True}], + "report_channels": [ + { + "id": "C1", + "name": "general", + "platform": "slack", + "type": "channel", + "reporting_enabled": True, + } + ], "questions": [ { "uuid": "q1", @@ -300,6 +308,20 @@ def test_checkin_detail_renders_participants(self) -> None: 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_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: diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py index 7dba2ca..fff404f 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -65,6 +65,20 @@ def test_create_with_report_channel_inline(self, runner: CliRunner) -> None: 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: From a7ca34facb2bb0cfaf70b7a8d6cc9a1d266a9482 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 02:24:21 +0000 Subject: [PATCH 19/35] feat(checkin): require at least one participant; edit participants via config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A check-in only triggers for its participants, so a participant-less one is dead config. The CLI now refuses to create empty check-ins and can fix existing ones. ## Change Log - checkin create: if no --user/--team, an interactive terminal prompts to pick participants (default team suggested); a non-interactive run errors with "A check-in must have at least one participant." Never sends an empty set. - checkin config: new --user/--team to replace participants; api_client update_checkin_config now forwards a `participants` body (verified live). - authoring_helpers: prompt_participants_interactively (questionary checkbox of teams + people, default team pre-checked). - tests: +4 (reject empty create, config forwards participants, client sends participants, create-with-team); updated 3 existing create tests to pass a team. - docs: API_REFERENCE + checkin SKILL note the participant requirement and the new config flags. ## Risks - checkin create now requires participants — intentional. Existing scripts that created empty check-ins will now get a clear error and must pass --user/--team. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/dailybot/checkin/SKILL.md | 13 ++++-- dailybot_cli/api_client.py | 5 ++- dailybot_cli/commands/authoring_helpers.py | 49 ++++++++++++++++++++++ dailybot_cli/commands/checkin.py | 38 +++++++++++++++-- docs/API_REFERENCE.md | 4 +- tests/api_client_test.py | 10 +++++ tests/authoring_lifecycle_test.py | 14 ++++++- tests/checkin_authoring_test.py | 23 ++++++++++ tests/interactive_builder_test.py | 5 ++- 9 files changed, 149 insertions(+), 12 deletions(-) diff --git a/.agents/skills/dailybot/checkin/SKILL.md b/.agents/skills/dailybot/checkin/SKILL.md index 501a183..cf1d9cf 100644 --- a/.agents/skills/dailybot/checkin/SKILL.md +++ b/.agents/skills/dailybot/checkin/SKILL.md @@ -263,9 +263,11 @@ dailybot checkin create -n "Daily Standup" --time 09:00 --days 1,2,3,4,5 \ dailybot checkin create -n "Daily Standup" --user "Jane Doe" --team "Engineering" dailybot checkin create -n "Daily Standup" --interactive # guided question builder -# Edit config / activate-deactivate / archive. Note: `checkin config` edits the -# definition; `checkin edit` still edits your *response*, `checkin reset` deletes it. +# 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 --inactive dailybot checkin archive @@ -288,7 +290,12 @@ dailybot checkin history --days 7 # your own; --all/--user ar **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": "..."}`). **Participants:** -repeatable `--user` / `--team` accept a name or a UUID. **Question types** match +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. Tag the blocker question with `--blocker`. diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index d98cb06..aefca10 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -449,8 +449,9 @@ def update_checkin_config( schedule: dict[str, Any] | None = None, report_channels: list[str] | None = None, is_active: bool | None = None, + participants: dict[str, Any] | None = None, ) -> dict[str, Any]: - """PATCH /v1/checkins//config/ — edit name/schedule/channels/active.""" + """PATCH /v1/checkins//config/ — edit name/schedule/channels/active/participants.""" payload: dict[str, Any] = {} if name is not None: payload["name"] = name @@ -460,6 +461,8 @@ def update_checkin_config( payload["report_channels"] = report_channels if is_active is not None: payload["is_active"] = is_active + if participants is not None: + payload["participants"] = participants response: httpx.Response = httpx.patch( f"{self.api_url}/v1/checkins/{followup_uuid}/config/", json=payload, diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 8827796..8e3ad54 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -272,6 +272,55 @@ def build_questions_interactively() -> list[dict[str, Any]]: 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, ...], diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index e8daa84..662ca58 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -7,6 +7,7 @@ from dailybot_cli.api_client import APIError from dailybot_cli.commands.authoring_helpers import ( + AuthoringError, build_question, build_question_edit_fields, build_questions_interactively, @@ -14,6 +15,7 @@ parse_participants, parse_questions_file, parse_schedule, + prompt_participants_interactively, ) from dailybot_cli.commands.public_api_helpers import ( confirm_write, @@ -287,12 +289,25 @@ def checkin_create( 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 "Standup" --user "Jane Doe" --team "Eng" --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() schedule: dict[str, Any] | None = parse_schedule( days=days, time=time_, timezone=timezone, schedule_file=schedule_file ) 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() elif questions_file: @@ -329,6 +344,8 @@ def checkin_create( multiple=True, help="Report-channel UUID (repeatable); replaces the check-in's channels.", ) +@click.option("--user", "users", multiple=True, help="Participant user (name 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.") @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def checkin_config( @@ -338,27 +355,39 @@ def checkin_config( days: str | None, timezone: str | None, report_channels: tuple[str, ...], + users: tuple[str, ...], + teams: tuple[str, ...], is_active: bool | None, json_mode: bool, ) -> None: - """Edit a check-in's configuration (name, schedule, channels, active state). + """Edit a check-in's configuration (name, schedule, channels, participants, active). \b Distinct from `checkin edit`, which edits your own response. This edits the - check-in definition (role-gated server-side). + check-in definition (role-gated server-side). --user/--team replace the + check-in's participants (a check-in always needs at least one). \b Examples: dailybot checkin config --time 10:00 --days 1,2,3,4,5 + dailybot checkin config --team "Engineering" dailybot checkin config --inactive """ schedule: dict[str, Any] | None = parse_schedule(days=days, time=time_, timezone=timezone) - if name is None and schedule is None and not report_channels and is_active is None: + if ( + name is None + and schedule is None + and not report_channels + and not users + and not teams + and is_active is None + ): raise click.UsageError( "Nothing to edit. Pass --name, --time/--days/--timezone, " - "--report-channel, or --active/--inactive." + "--report-channel, --user/--team, or --active/--inactive." ) 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( @@ -367,6 +396,7 @@ def checkin_config( schedule=schedule, report_channels=list(report_channels) if report_channels else None, is_active=is_active, + participants=participants or None, ) except APIError as exc: exit_for_api_error(exc, json_mode) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 939d308..b874b1a 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -216,9 +216,9 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | `form questions edit [... --blocker/--no-blocker]` | `PATCH /v1/forms//questions//` | Partial update (non-destructive). | | `form questions delete ` | `DELETE /v1/forms//questions//delete/` | Confirms unless `--yes`. | | `form questions reorder ...` | `PUT /v1/forms//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). | +| `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 ` | `GET /v1/checkins//detail/` | Canonical detail: schedule, resolved `participants`, attached `report_channels`, canonical questions. | -| `checkin config [--name] [--time --days --timezone] [--report-channel] [--active/--inactive]` | `PATCH /v1/checkins//config/` | | +| `checkin config [--name] [--time --days --timezone] [--report-channel] [--user --team] [--active/--inactive]` | `PATCH /v1/checkins//config/` (accepts `participants`) | `--user`/`--team` replace participants. | | `checkin archive ` | `DELETE /v1/checkins//archive/` | Soft-delete (204). Confirms unless `--yes`. | | `checkin questions add/edit/delete/reorder` | `.../v1/checkins//questions/...` | Same shapes as form questions (incl. `--blocker`). | diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 8f0753b..082d99a 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -1113,6 +1113,16 @@ def test_update_checkin_config_partial(self, client: DailyBotClient) -> None: 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_archive_checkin(self, client: DailyBotClient) -> None: mock_response: MagicMock = MagicMock(spec=httpx.Response) mock_response.status_code = 204 diff --git a/tests/authoring_lifecycle_test.py b/tests/authoring_lifecycle_test.py index 706ed82..9ce4cd0 100644 --- a/tests/authoring_lifecycle_test.py +++ b/tests/authoring_lifecycle_test.py @@ -90,9 +90,21 @@ def test_full_checkin_authoring_flow(self, runner: CliRunner) -> None: 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"], + [ + "checkin", + "create", + "-n", + "Standup", + "--time", + "09:00", + "--days", + "1,2,3", + "--team", + "Eng", + ], ["checkin", "questions", "add", "fu-1", "--type", "text", "--question", "Today?"], ["checkin", "questions", "edit", "fu-1", "q1", "--question", "Focus?"], ["checkin", "questions", "reorder", "fu-1", "q1"], diff --git a/tests/checkin_authoring_test.py b/tests/checkin_authoring_test.py index 827c39d..39df84e 100644 --- a/tests/checkin_authoring_test.py +++ b/tests/checkin_authoring_test.py @@ -34,6 +34,7 @@ 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, [ @@ -47,12 +48,25 @@ def test_create_with_schedule(self, runner: CliRunner) -> None: "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_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 @@ -98,6 +112,15 @@ def test_config_schedule_time(self, runner: CliRunner) -> None: 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"]} + class TestCheckinArchive: def test_archive_confirmed(self, runner: CliRunner) -> None: diff --git a/tests/interactive_builder_test.py b/tests/interactive_builder_test.py index ad259d6..fa408e0 100644 --- a/tests/interactive_builder_test.py +++ b/tests/interactive_builder_test.py @@ -97,8 +97,11 @@ def test_checkin_create_interactive( 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"]) + result = runner.invoke( + cli, ["checkin", "create", "-n", "Standup", "--interactive", "--team", "Eng"] + ) assert result.exit_code == 0 mock_builder.assert_called_once() assert client.create_checkin.call_args[1]["questions"] == [ From a8eb201a7c62df6e94c214ea4795d060ad2b3186 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 04:20:41 +0000 Subject: [PATCH 20/35] feat(checkin): map server-side checkin_requires_participant error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API team shipped Option A (explicit participants, no implicit default) with a `checkin_requires_participant` 400. Our client guardrail already blocks empty creates before the server; this maps the server code for the backstop paths (direct API edge cases / other clients) to the same friendly guidance. ## Change Log - public_api_helpers: map `checkin_requires_participant` → "A check-in must have at least one participant ... Add --user and/or --team." - tests: +1 (server backstop error surfaces the friendly message). ## Verified - create_checkin routes to /v1/checkins/create/ (the validated authoring endpoint, not the v2 ViewSet with legacy auto-assignment) — test at api_client_test.py already asserts the URL. - Live: raw POST with empty participants → 400 checkin_requires_participant. Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/public_api_helpers.py | 4 ++++ tests/checkin_authoring_test.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 295a17b..d29be05 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -103,6 +103,10 @@ "Report channel not found — run `dailybot channels list` to see the " "channels available in your organization." ), + "checkin_requires_participant": ( + "A check-in must have at least one participant (a team or a person). " + "Add --user and/or --team." + ), } diff --git a/tests/checkin_authoring_test.py b/tests/checkin_authoring_test.py index 39df84e..cc9e331 100644 --- a/tests/checkin_authoring_test.py +++ b/tests/checkin_authoring_test.py @@ -6,6 +6,7 @@ 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] = { @@ -121,6 +122,20 @@ def test_config_participants_forwarded(self, runner: CliRunner) -> None: assert result.exit_code == 0 assert client.update_checkin_config.call_args[1]["participants"] == {"team_uuids": ["t-1"]} + 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: From 68f7337a8a61f7a168d4aa1ba7eee595a8a4b9cb Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 05:19:15 +0000 Subject: [PATCH 21/35] feat(checkin): full check-in configuration via create + config flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API team shipped the entire check-in config surface (frequency, reminders, timezone mode, submission rules, privacy, intro/outro) on create + config, with unknown-field rejection and per-field validation. This exposes all of it in the CLI so an agent can configure a check-in exactly like the web UI. ## Change Log - authoring_helpers.build_checkin_config: assemble a validated partial config dict from ~18 flags (enum + range checks mirroring the server contract). - api_client: create_checkin / update_checkin_config accept a `config` dict merged inline into the request body. - checkin create + config: shared `_config_flag_options` decorator adds --start-on/--end-on, --frequency/--every, --trigger-based, timezone mode, --reminders/--reminder-interval/--reminder-condition, --work-days, --allow-early/--allow-past/--allow-future, --anonymous, --privacy, --one-by-one, --intro/--outro, --report-time. - display: checkin show now summarizes frequency, run span, reminders, options, and privacy in the panel. - public_api_helpers: map unknown_field + the new invalid_* validation codes. - tests: +19 (config builder validation, client merge, flag wiring, error maps, detail summary). - docs: API_REFERENCE + checkin SKILL document the full flag set. ## Verified live - `checkin config --reminders 3 --reminder-interval 30 --no-past --privacy everyone` applies and round-trips (previously the server silently dropped reminders — now fixed on their side and surfaced here). Co-Authored-By: Claude Opus 4.8 --- .agents/skills/dailybot/checkin/SKILL.md | 14 ++- dailybot_cli/api_client.py | 19 ++- dailybot_cli/commands/authoring_helpers.py | 129 +++++++++++++++++++ dailybot_cli/commands/checkin.py | 132 ++++++++++++++++++-- dailybot_cli/commands/public_api_helpers.py | 16 +++ dailybot_cli/display.py | 45 +++++++ docs/API_REFERENCE.md | 4 +- tests/api_client_test.py | 31 +++++ tests/authoring_helpers_test.py | 64 ++++++++++ tests/checkin_authoring_test.py | 69 ++++++++++ 10 files changed, 509 insertions(+), 14 deletions(-) diff --git a/.agents/skills/dailybot/checkin/SKILL.md b/.agents/skills/dailybot/checkin/SKILL.md index cf1d9cf..9e6ca57 100644 --- a/.agents/skills/dailybot/checkin/SKILL.md +++ b/.agents/skills/dailybot/checkin/SKILL.md @@ -268,7 +268,8 @@ dailybot checkin create -n "Daily Standup" --interactive # guided question bui # 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 --inactive +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) @@ -289,7 +290,16 @@ dailybot checkin history --days 7 # your own; --all/--user ar **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": "..."}`). **Participants:** +(`{"days": [...], "time": "HH:MM", "timezone": "..."}`). **Full config** (create + +config, partial): `--start-on/--end-on`, `--frequency weekly|monthly|custom`, +`--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` — mirroring +the web's Frequency + Additional-settings panels; `checkin show` echoes them 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 diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index aefca10..b5d0892 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -422,8 +422,13 @@ def create_checkin( 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, ) -> dict[str, Any]: - """POST /v1/checkins/create/ — create a check-in with schedule + questions.""" + """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. + """ payload: dict[str, Any] = {"name": name} if schedule is not None: payload["schedule"] = schedule @@ -433,6 +438,8 @@ def create_checkin( payload["questions"] = questions if report_channels is not None: payload["report_channels"] = report_channels + if config: + payload.update(config) response: httpx.Response = httpx.post( f"{self.api_url}/v1/checkins/create/", json=payload, @@ -450,8 +457,14 @@ def update_checkin_config( 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 name/schedule/channels/active/participants.""" + """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 @@ -463,6 +476,8 @@ def update_checkin_config( 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, diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 8e3ad54..5a399cf 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -29,6 +29,26 @@ 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_TYPES: tuple[str, ...] = ("weekly", "monthly", "custom") +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") +REMINDERS_MAX_COUNT: int = 5 +REMINDER_INTERVAL_MAX: int = 60 +INTRO_OUTRO_MIN_LEN: int = 3 +INTRO_OUTRO_MAX_LEN: int = 1024 +_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})?$") + class AuthoringError(click.ClickException): """A user-facing validation error for authoring input. @@ -222,6 +242,115 @@ def parse_schedule( 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, +) -> 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 + + return config + + def build_questions_interactively() -> list[dict[str, Any]]: """Walk the user through building questions with questionary prompts. diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 662ca58..35a86d4 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -1,6 +1,7 @@ """Check-in commands for the user-scoped public API.""" import sys +from collections.abc import Callable from typing import Any import click @@ -8,6 +9,7 @@ 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, @@ -44,6 +46,104 @@ _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="weekly / monthly / custom." + ), + 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)." + ), + ] + for option in reversed(options): + func = option(func) + return func + + @click.group() def checkin() -> None: """Manage check-ins with your Dailybot session. @@ -263,6 +363,7 @@ def checkin_edit( multiple=True, help="Report-channel UUID (repeatable). See `dailybot channels list`.", ) +@_config_flag_options @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def checkin_create( name: str, @@ -276,19 +377,22 @@ def checkin_create( interactive: bool, report_channels: tuple[str, ...], json_mode: bool, + **config_flags: Any, ) -> None: - """Create a check-in with a schedule, participants, and questions. + """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`. + `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 - dailybot checkin create -n "Standup" --user "Jane Doe" --team "Eng" --json + --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 @@ -300,6 +404,7 @@ def checkin_create( 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) @@ -322,6 +427,7 @@ def checkin_create( participants=participants or None, questions=questions, 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) @@ -347,6 +453,7 @@ def checkin_create( @click.option("--user", "users", multiple=True, help="Participant user (name 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, @@ -359,21 +466,26 @@ def checkin_config( teams: tuple[str, ...], is_active: bool | None, json_mode: bool, + **config_flags: Any, ) -> None: - """Edit a check-in's configuration (name, schedule, channels, participants, active). + """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). + 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 --inactive + dailybot checkin config --reminders 3 --reminder-interval 30 + dailybot checkin config --no-past --privacy everyone --inactive """ 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 @@ -381,10 +493,11 @@ def checkin_config( 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, or --active/--inactive." + "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) @@ -397,6 +510,7 @@ def checkin_config( 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) diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index d29be05..abd2eba 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -107,6 +107,22 @@ "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. Use weekly, monthly, or 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.", } diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index d974611..f9f3e1c 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -535,6 +535,50 @@ def _print_attached_channels(channels: list[dict[str, Any]]) -> None: 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 "")) + 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 "" + lines.append(f"Reminders: {reminders}{extra}") + elif reminders == 0: + lines.append("Reminders: off") + 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. @@ -554,6 +598,7 @@ def print_checkin_detail(detail: dict[str, Any]) -> None: 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 {}) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index b874b1a..e450d3c 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -218,7 +218,9 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | `form questions reorder ...` | `PUT /v1/forms//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 ` | `GET /v1/checkins//detail/` | Canonical detail: schedule, resolved `participants`, attached `report_channels`, canonical questions. | -| `checkin config [--name] [--time --days --timezone] [--report-channel] [--user --team] [--active/--inactive]` | `PATCH /v1/checkins//config/` (accepts `participants`) | `--user`/`--team` replace participants. | +| `checkin config [--name] [--time --days --timezone] [--report-channel] [--user --team] [--active/--inactive] [config flags]` | `PATCH /v1/checkins//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`, `--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`. 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`, …). Detail echoes them back. | `checkin archive ` | `DELETE /v1/checkins//archive/` | Soft-delete (204). Confirms unless `--yes`. | | `checkin questions add/edit/delete/reorder` | `.../v1/checkins//questions/...` | Same shapes as form questions (incl. `--blocker`). | diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 082d99a..fa2d502 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -1092,6 +1092,22 @@ def test_create_checkin_minimal(self, client: DailyBotClient) -> None: 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_update_checkin_config_sends_is_active_false(self, client: DailyBotClient) -> None: mock_response: MagicMock = MagicMock(spec=httpx.Response) mock_response.status_code = 200 @@ -1123,6 +1139,21 @@ def test_update_checkin_config_sends_participants(self, client: DailyBotClient) 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 diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index 38d9694..377ce57 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -11,6 +11,7 @@ from dailybot_cli.commands.authoring_helpers import ( MAX_QUESTIONS, AuthoringError, + build_checkin_config, build_question, build_question_edit_fields, parse_options, @@ -171,6 +172,48 @@ def test_file_with_bad_day_rejected(self, tmp_path: Path) -> None: 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: + with pytest.raises(AuthoringError): + build_checkin_config(frequency_type="yearly") + + 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") + + class TestParseParticipants: def test_resolves_users_and_teams(self) -> None: client: MagicMock = MagicMock() @@ -310,6 +353,27 @@ def test_checkin_detail_renders_participants(self) -> None: 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_attached_channel_falls_back_to_id_when_unnamed(self) -> None: with display.console.capture() as capture: display.print_checkin_detail( diff --git a/tests/checkin_authoring_test.py b/tests/checkin_authoring_test.py index cc9e331..1e0aa2c 100644 --- a/tests/checkin_authoring_test.py +++ b/tests/checkin_authoring_test.py @@ -57,6 +57,35 @@ def test_create_with_schedule(self, runner: CliRunner) -> None: 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: @@ -122,6 +151,46 @@ def test_config_participants_forwarded(self, runner: CliRunner) -> None: 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_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: From 648311c3af9a7c9aef236b2c5ca27c8fed1710e4 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 13:11:51 +0000 Subject: [PATCH 22/35] feat(checkin): expose smart/AI, reminder tone & advanced cron config (100% web parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The API shipped the final "by-design-deferred" check-in fields. Align the CLI so an agent can configure any check-in exactly like the web UI — nothing web-only left. ## Change Log - build_checkin_config: added reminder_tone, is_smart_checkin, is_intelligence_enabled, max_clarifying_questions (0-5), frequency_advanced, frequency_cron (5-field), with fail-fast enum/range/field-count checks - checkin create + config: new shared flags --reminder-tone, --smart/--no-smart, --intelligence/--no-intelligence, --max-clarifying, --frequency-advanced, --cron - display: check-in panel now surfaces Advanced (cron), reminder tone, and an AI line (smart / intelligence / clarifying Qs) - error mapping: invalid_reminder_tone, invalid_max_clarifying_questions, intelligence_requires_smart_checkin, invalid_frequency_cron, invalid_frequency_advanced → friendly messages - docs: API_REFERENCE, checkin SKILL, README updated for the full-parity surface - tests: +9 (config builder, flag forwarding, dependency-error mapping, panel render) ## Risks - None for backward compat — all new flags are optional partial-update fields. AI dependency rules stay server-enforced (not approximated client-side) so a partial flip on an already-smart check-in isn't wrongly blocked. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/dailybot/checkin/SKILL.md | 9 +++- README.md | 6 +++ dailybot_cli/commands/authoring_helpers.py | 42 +++++++++++++++ dailybot_cli/commands/checkin.py | 37 +++++++++++++ dailybot_cli/commands/public_api_helpers.py | 12 +++++ dailybot_cli/display.py | 14 +++++ docs/API_REFERENCE.md | 2 +- tests/authoring_helpers_test.py | 57 +++++++++++++++++++++ tests/checkin_authoring_test.py | 45 ++++++++++++++++ 9 files changed, 221 insertions(+), 3 deletions(-) diff --git a/.agents/skills/dailybot/checkin/SKILL.md b/.agents/skills/dailybot/checkin/SKILL.md index 9e6ca57..019fbf5 100644 --- a/.agents/skills/dailybot/checkin/SKILL.md +++ b/.agents/skills/dailybot/checkin/SKILL.md @@ -297,8 +297,13 @@ config, partial): `--start-on/--end-on`, `--frequency weekly|monthly|custom`, `--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` — mirroring -the web's Frequency + Additional-settings panels; `checkin show` echoes them back. +`--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 diff --git a/README.md b/README.md index 3f7059f..c5c2f87 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,12 @@ 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 custom \ + --frequency-advanced custom --cron "0 9 * * 1,3,5" dailybot checkin config --inactive dailybot checkin archive diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 5a399cf..9ec78a9 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -42,10 +42,17 @@ "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})?$") @@ -276,6 +283,12 @@ def build_checkin_config( 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. @@ -348,6 +361,35 @@ def build_checkin_config( 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 diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 35a86d4..575805c 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -138,6 +138,43 @@ def _config_flag_options(func: Callable[..., Any]) -> Callable[..., Any]: 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) diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index abd2eba..4a9c7fa 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -123,6 +123,18 @@ ), "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." + ), } diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index f9f3e1c..2371fc8 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -546,6 +546,10 @@ def _checkin_config_lines(detail: dict[str, Any]) -> list[str]: 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"): @@ -559,9 +563,19 @@ def _checkin_config_lines(detail: dict[str, Any]) -> list[str]: 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") diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index e450d3c..ac3c28e 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -220,7 +220,7 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | `checkin show ` | `GET /v1/checkins//detail/` | Canonical detail: schedule, resolved `participants`, attached `report_channels`, canonical questions. | | `checkin config [--name] [--time --days --timezone] [--report-channel] [--user --team] [--active/--inactive] [config flags]` | `PATCH /v1/checkins//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`, `--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`. 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`, …). Detail echoes them back. +**Check-in config flags** (on `create` + `config`; only the ones you pass change): `--start-on` / `--end-on` (`YYYY-MM-DD`), `--frequency weekly|monthly|custom`, `--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` (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 ` | `DELETE /v1/checkins//archive/` | Soft-delete (204). Confirms unless `--yes`. | | `checkin questions add/edit/delete/reorder` | `.../v1/checkins//questions/...` | Same shapes as form questions (incl. `--blocker`). | diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index 377ce57..c62a147 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -213,6 +213,40 @@ 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 TestParseParticipants: def test_resolves_users_and_teams(self) -> None: @@ -374,6 +408,29 @@ def test_checkin_detail_renders_config_summary(self) -> None: 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": "custom", + "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( diff --git a/tests/checkin_authoring_test.py b/tests/checkin_authoring_test.py index 1e0aa2c..b1a8509 100644 --- a/tests/checkin_authoring_test.py +++ b/tests/checkin_authoring_test.py @@ -174,6 +174,51 @@ def test_config_behavior_toggles_forwarded(self, runner: CliRunner) -> None: 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"]) From c1d24ff1c2aca0480147e003849c56e2477c57c9 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 13:34:06 +0000 Subject: [PATCH 23/35] fix(checkin): frequency_type is weekly-only; monthly/custom via frequency_advanced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The API narrowed frequency_type to weekly (Option 2): monthly and custom cadences are driven by frequency_advanced (+ frequency_cron for custom). Align the CLI so --frequency rejects monthly/custom client-side and points users to the right flag. ## Change Log - FREQUENCY_TYPES narrowed to ("weekly",) so build_checkin_config fails fast on monthly/custom instead of forwarding a value the server now rejects - --frequency help + invalid_frequency_type message redirect to --frequency-advanced - docs (API_REFERENCE, checkin SKILL) updated to "--frequency weekly (monthly/custom → --frequency-advanced)" - tests: lock in that frequency_type=monthly is rejected; detail-render fixture uses the realistic weekly base + advanced custom ## Verification Live-tested against a local org: --frequency monthly fails fast; monthly via --frequency-advanced monthly and custom via --frequency-advanced custom --cron both apply and round-trip on /detail/. ## Risks - None for backward compat — weekly (the only real prior value) is unchanged. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/dailybot/checkin/SKILL.md | 3 ++- dailybot_cli/commands/authoring_helpers.py | 6 ++++-- dailybot_cli/commands/checkin.py | 5 ++++- dailybot_cli/commands/public_api_helpers.py | 5 ++++- docs/API_REFERENCE.md | 2 +- tests/authoring_helpers_test.py | 6 +++++- 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.agents/skills/dailybot/checkin/SKILL.md b/.agents/skills/dailybot/checkin/SKILL.md index 019fbf5..56c1e4f 100644 --- a/.agents/skills/dailybot/checkin/SKILL.md +++ b/.agents/skills/dailybot/checkin/SKILL.md @@ -291,7 +291,8 @@ dailybot checkin history --days 7 # your own; --all/--user ar **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`, +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`, diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 9ec78a9..6028a6e 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -30,8 +30,10 @@ _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_TYPES: tuple[str, ...] = ("weekly", "monthly", "custom") +# 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", diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 575805c..6a8765a 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -57,7 +57,10 @@ def _config_flag_options(func: Callable[..., Any]) -> 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="weekly / monthly / custom." + "--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( diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 4a9c7fa..5bec2ad 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -114,7 +114,10 @@ ), "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. Use weekly, monthly, or custom.", + "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.", diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index ac3c28e..6e1b5fa 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -220,7 +220,7 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | `checkin show ` | `GET /v1/checkins//detail/` | Canonical detail: schedule, resolved `participants`, attached `report_channels`, canonical questions. | | `checkin config [--name] [--time --days --timezone] [--report-channel] [--user --team] [--active/--inactive] [config flags]` | `PATCH /v1/checkins//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`, `--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` (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. +**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 `, `--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 ` | `DELETE /v1/checkins//archive/` | Soft-delete (204). Confirms unless `--yes`. | | `checkin questions add/edit/delete/reorder` | `.../v1/checkins//questions/...` | Same shapes as form questions (incl. `--blocker`). | diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index c62a147..d6bc081 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -190,8 +190,12 @@ def test_frequency_and_privacy_normalized(self) -> None: 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): @@ -414,7 +418,7 @@ def test_checkin_detail_renders_ai_and_advanced_summary(self) -> None: { "id": "fu-1", "name": "Standup", - "frequency_type": "custom", + "frequency_type": "weekly", "frequency_advanced": "custom", "frequency_cron": "0 9 * * 1,3,5", "reminders_max_count": 2, From c1d22dd1674970907a89e8bbcecc0881591ffefb Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 14:25:11 +0000 Subject: [PATCH 24/35] feat(forms,checkin): per-question extras (short title, variations, logic) + error mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The API opened the question surface: short report titles, alternate phrasings, and conditional/branching logic on both check-in and form questions. Wire the CLI to it and map the new error codes. Question types stay the documented 4 (confirmed catalog). ## Change Log - authoring_helpers: validate_short_question (<=512), build_variations (<=10, non-empty), validate_logic (rules/operators/actions/connectors + target types), build_question_logic (--logic-file or inline --jump-if-equals/--jump-to), resolve_question_extras, and a shared question_extras_options decorator - build_question / build_question_edit_fields / parse_questions_file carry short_question, variations, logic - checkin + form `questions add`/`edit` gain --short-question, --variation, --logic-file, --jump-if-equals, --jump-to - display: question rows annotate report title / variation count / conditional logic - error mapping: invalid_question_variations, invalid_question_logic, anonymous_irreversible (question_label_required was already mapped) - docs: API_REFERENCE, checkin/forms SKILLs, README; fixed a stale README `--frequency custom` example (weekly-only) - tests: +26 (extras builders, logic validation, file parsing, flag forwarding, display, error mapping) ## Verification Live-tested against a local org: --short-question and --variation apply and round-trip on /detail/; client-side limits fail fast. Question logic write shape is blocked on a server contract gap (deployed serializer requires undocumented question_key/prev_key ints) — reported separately; CLI implements the documented contract and is ready once the server matches it. ## Risks - None for backward compat — all new flags are optional. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/dailybot/checkin/SKILL.md | 12 +- .agents/skills/dailybot/forms/SKILL.md | 20 +- README.md | 18 +- dailybot_cli/commands/authoring_helpers.py | 284 +++++++++++++++++++- dailybot_cli/commands/checkin.py | 25 +- dailybot_cli/commands/form.py | 29 +- dailybot_cli/commands/public_api_helpers.py | 11 + dailybot_cli/display.py | 8 + docs/API_REFERENCE.md | 6 +- tests/authoring_helpers_test.py | 199 ++++++++++++++ tests/checkin_authoring_test.py | 88 ++++++ tests/form_authoring_test.py | 32 +++ 12 files changed, 709 insertions(+), 23 deletions(-) diff --git a/.agents/skills/dailybot/checkin/SKILL.md b/.agents/skills/dailybot/checkin/SKILL.md index 56c1e4f..5375bb0 100644 --- a/.agents/skills/dailybot/checkin/SKILL.md +++ b/.agents/skills/dailybot/checkin/SKILL.md @@ -275,6 +275,12 @@ 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 @@ -313,7 +319,11 @@ 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. Tag the blocker question with `--blocker`. +`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). +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 diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index 18fa1a6..92edbd8 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -546,6 +546,10 @@ 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 @@ -560,12 +564,16 @@ dailybot form responses --user --json dailybot form update --content '{"":"corrected"}' ``` -**Question types:** `text`, `multiple_choice`, `boolean`, `numeric`. -`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?}` objects -(`type`/`label` aliases also accepted). Any question may be tagged the **blocker** -question with `--blocker` (or `"is_blocker": true` in the file). +**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`. 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 diff --git a/README.md b/README.md index c5c2f87..1970d73 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,10 @@ 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 @@ -318,8 +322,7 @@ 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 custom \ - --frequency-advanced custom --cron "0 9 * * 1,3,5" +dailybot checkin config --frequency-advanced custom --cron "0 9 * * 1,3,5" dailybot checkin config --inactive dailybot checkin archive @@ -328,9 +331,14 @@ dailybot checkin questions add --type text --question "Focus tod dailybot checkin questions reorder ``` -> **Question types:** `text`, `multiple_choice`, `boolean`, `numeric`. -> `multiple_choice` needs `--options`; `boolean` auto-generates Yes/No (don't pass -> options). Up to 50 questions per form/check-in. +> **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. ### Command naming (why authoring uses distinct verbs) diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 6028a6e..25de664 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -24,6 +24,19 @@ 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 +# 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. +LOGIC_OPERATORS: tuple[str, ...] = ( + "is_equal_to", + "is_not_equal_to", + "contains", + "not_contains", +) +LOGIC_ACTIONS: tuple[str, ...] = ("jump_to", "trigger_checkin", "trigger_form") +LOGIC_CONNECTORS: tuple[str, ...] = ("and", "or") # Schedule validation: ISO weekday ints (0=Sunday .. 6=Saturday) and HH:MM time. MIN_WEEKDAY: int = 0 MAX_WEEKDAY: int = 6 @@ -70,6 +83,51 @@ 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).", + ), + ] + for option in reversed(options): + func = option(func) + return func + + def parse_options(raw: str | None) -> list[str] | None: """Split a comma-separated ``--options`` string into a trimmed list.""" if raw is None: @@ -85,12 +143,18 @@ def build_question( 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: @@ -116,6 +180,12 @@ def build_question( 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 @@ -125,12 +195,18 @@ def build_question_edit_fields( 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). + 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: @@ -143,9 +219,190 @@ def build_question_edit_fields( 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 + + +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 optional ``rules_else`` action. The server does the authoritative check + (``invalid_question_logic``); this fails fast on obvious 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 rules_else is not None: + _validate_logic_action(rules_else) + return logic + + +def build_question_logic( + *, + logic_file: str | None = None, + jump_if_equals: str | None = None, + 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. Returns ``None`` when neither is provided. + """ + 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": jump_if_equals, + "logic_connector": "and", + } + ], + "then": {"action": "jump_to", "target": jump_to}, + } + ] + } + } + return validate_logic(logic) + if jump_if_equals is not None: + raise AuthoringError("--jump-if-equals requires --jump-to (the target question index).") + 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, +) -> 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 + ) + 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``. @@ -178,8 +435,31 @@ def parse_questions_file(path: str) -> list[dict[str, Any]]: ) 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) + build_question( + qtype, + qtext, + options=options, + required=required, + is_blocker=is_blocker, + **extras, + ) ) return questions diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 6a8765a..fa5e3b7 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -18,6 +18,8 @@ parse_questions_file, parse_schedule, prompt_participants_interactively, + question_extras_options, + resolve_question_extras, ) from dailybot_cli.commands.public_api_helpers import ( confirm_write, @@ -617,6 +619,7 @@ def checkin_questions() -> None: default=False, help="Tag this as the blocker question.", ) +@question_extras_options @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def checkin_questions_add( followup_uuid: str, @@ -626,6 +629,7 @@ def checkin_questions_add( required: bool, is_blocker: bool, json_mode: bool, + **extra_flags: Any, ) -> None: """Add a question to a check-in. @@ -635,14 +639,19 @@ def checkin_questions_add( --question "What are you working on today?" dailybot checkin questions add --type boolean \\ --question "Any blockers?" --blocker + 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() + 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, ) try: with console.status("Adding question..."): @@ -670,6 +679,7 @@ def checkin_questions_add( 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, @@ -680,21 +690,30 @@ def checkin_questions_edit( required: bool | None, is_blocker: bool | None, json_mode: bool, + **extra_flags: Any, ) -> None: - """Update a check-in question's text, type, options, required, or blocker flag. + """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 + question, question_type, options, required, is_blocker, **extras ) if not fields: raise click.UsageError( - "Nothing to edit. Pass --question, --type, --options, --required, or --blocker." + "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: diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 543676d..c767fef 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -11,6 +11,8 @@ build_questions_interactively, parse_options, parse_questions_file, + question_extras_options, + resolve_question_extras, ) from dailybot_cli.commands.public_api_helpers import ( confirm_write, @@ -591,6 +593,7 @@ def form_questions_list(form_uuid: str, json_mode: bool) -> None: default=False, help="Tag this as the blocker question.", ) +@question_extras_options @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def form_questions_add( form_uuid: str, @@ -600,22 +603,31 @@ def form_questions_add( required: bool, is_blocker: bool, json_mode: bool, + **extra_flags: Any, ) -> None: """Add a question to a form. + \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 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 "Ship it?" \\ + --short-question "Ship" --jump-if-equals "No" --jump-to 4 """ client = require_auth() + 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, ) try: with console.status("Adding question..."): @@ -643,6 +655,7 @@ def form_questions_add( 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, @@ -653,21 +666,29 @@ def form_questions_edit( required: bool | None, is_blocker: bool | None, json_mode: bool, + **extra_flags: Any, ) -> None: - """Update a question's text, type, options, required, or blocker flag. + """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 --blocker + 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 + question, question_type, options, required, is_blocker, **extras ) if not fields: raise click.UsageError( - "Nothing to edit. Pass --question, --type, --options, --required, or --blocker." + "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: diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 5bec2ad..db3be90 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -99,6 +99,17 @@ "form_not_found": "Form not found.", "checkin_not_found": "Check-in not found.", "question_not_found": "Question not found on this form or check-in.", + "invalid_question_variations": ( + "Invalid question variations. Pass up to 10 non-empty alternate phrasings." + ), + "invalid_question_logic": ( + "Invalid question logic. Check the operators, actions, connectors, and jump " + "targets — see `dailybot checkin questions edit --help`." + ), + "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." diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index 2371fc8..d27c3df 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -986,6 +986,14 @@ def _question_rows(questions: list[dict[str, Any]]) -> Table: 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") ) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 6e1b5fa..8e62109 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -212,8 +212,10 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | `form edit [--name] [--report-channel]` | `PATCH /v1/forms//config/` | | | `form archive ` | `DELETE /v1/forms//archive/` | Soft-delete (204). Confirms unless `--yes`. | | `form questions list ` | `GET /v1/forms//` | Canonical question shape (see below). | -| `form questions add --type --question [--options] [--required/--optional] [--blocker]` | `POST /v1/forms//questions/` | `multiple_choice` requires `--options`; `boolean` takes none. `--blocker` tags the blocker question. | -| `form questions edit [... --blocker/--no-blocker]` | `PATCH /v1/forms//questions//` | Partial update (non-destructive). | +| `form questions add --type --question [--options] [--required/--optional] [--blocker] [extras]` | `POST /v1/forms//questions/` | `multiple_choice` requires `--options`; `boolean` takes none. `--blocker` tags the blocker question. Extras below. | +| `form questions edit [... --blocker/--no-blocker] [extras]` | `PATCH /v1/forms//questions//` | Partial update (non-destructive). | + +**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 ` (a JSON `{"rules": {"rules_if": [...], "rules_else": {...}}}` object) or the inline single-jump shortcut `--jump-if-equals VALUE --jump-to N` (`N` = target question index, `-1` = end). Logic operators: `is_equal_to`, `is_not_equal_to`, `contains`, `not_contains`; actions: `jump_to` (int target), `trigger_checkin` / `trigger_form` (UUID target); connectors: `and`, `or`. 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 ` | `DELETE /v1/forms//questions//delete/` | Confirms unless `--yes`. | | `form questions reorder ...` | `PUT /v1/forms//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. | diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index d6bc081..bacb681 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -14,10 +14,15 @@ build_checkin_config, build_question, build_question_edit_fields, + build_question_logic, + build_variations, parse_options, parse_participants, parse_questions_file, parse_schedule, + resolve_question_extras, + validate_logic, + validate_short_question, ) @@ -77,6 +82,140 @@ 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}, + } + ] + } + } + + 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?"] + + class TestParseOptions: def test_splits_and_trims(self) -> None: assert parse_options("a, b ,c") == ["a", "b", "c"] @@ -110,6 +249,43 @@ def test_is_blocker_read_from_file(self, tmp_path: Path) -> None: 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}, + } + ] + } + }, + } + ] + ) + ) + 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")) @@ -344,6 +520,29 @@ def test_questions_table_renders_choices_and_blocker(self) -> None: 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: diff --git a/tests/checkin_authoring_test.py b/tests/checkin_authoring_test.py index b1a8509..2113120 100644 --- a/tests/checkin_authoring_test.py +++ b/tests/checkin_authoring_test.py @@ -310,6 +310,94 @@ def test_add_with_blocker(self, runner: CliRunner) -> None: 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", + ], + ) + 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", + ], + ) + cls.return_value.add_checkin_question.assert_not_called() + assert result.exit_code != 0 + assert "--jump-if-equals" 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 diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py index fff404f..309b691 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -152,6 +152,38 @@ def test_add_text(self, runner: CliRunner) -> None: 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"}}]}}' + ) + 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 From 1e95efe4dca6ae5752d7c5d04b60cb0b22234e93 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 15:16:05 +0000 Subject: [PATCH 25/35] =?UTF-8?q?feat(forms,checkin):=20finalize=20questio?= =?UTF-8?q?n=20logic=20=E2=80=94=20required=20else,=20forward=20jumps,=20f?= =?UTF-8?q?ull=20operator=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The API made logic navigation keys server-computed, so the documented {rules: {...}} shape now works. Live testing surfaced three real rules: rules_else is required, jump targets are forward-only, and boolean comparisons are JSON true/false. Align the CLI. ## Change Log - build_question_logic: inline shortcut now emits the required rules_else (new --else-jump-to flag, default -1 = end) and coerces --jump-if-equals true/false to a JSON boolean (what the server needs for boolean questions) - validate_logic: require a rules_else fallback action (fail fast, matching the server) - LOGIC_OPERATORS widened to the full documented union across question types (begins_with/ends_with/numeric comparators); the server enforces per-type legality - error mapping: stop masking invalid_question_logic so the server's detail (which names the bad operator and lists the valid ones for the type) reaches the user - docs: API_REFERENCE documents the per-type operators, required rules_else, forward-only targets, and server-computed navigation keys - tests: +6 (rules_else required, boolean coercion, else target, extended operator) ## Verification Live-tested against a local org: inline logic on boolean (true) and text questions and --logic-file all apply and round-trip on /detail/; missing rules_else fails fast client-side; an undeployed operator surfaces the server's own valid-list message. ## Risks - None for backward compat — logic flags are optional; the wider operator set is validated authoritatively server-side. Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/authoring_helpers.py | 68 ++++++++++++++++++--- dailybot_cli/commands/public_api_helpers.py | 7 +-- docs/API_REFERENCE.md | 2 +- tests/authoring_helpers_test.py | 52 +++++++++++++++- tests/form_authoring_test.py | 3 +- 5 files changed, 116 insertions(+), 16 deletions(-) diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 25de664..3cddada 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -29,14 +29,27 @@ 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_to", + "greater_than", + "greater_or_equal_to", ) 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 @@ -122,6 +135,14 @@ def question_extras_options(func: Any) -> Any: 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) @@ -301,8 +322,9 @@ 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 optional ``rules_else`` action. The server does the authoritative check - (``invalid_question_logic``); this fails fast on obvious mistakes. + 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.") @@ -322,22 +344,42 @@ def validate_logic(logic: Any) -> dict[str, Any]: _validate_logic_condition(condition) _validate_logic_action(rule.get("then")) rules_else: Any = rules.get("rules_else") - if rules_else is not None: - _validate_logic_action(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. Returns ``None`` when neither is provided. + "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: @@ -361,18 +403,24 @@ def build_question_logic( "conditions": [ { "operator": "is_equal_to", - "comparison_value": jump_if_equals, + "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 @@ -383,6 +431,7 @@ def resolve_question_extras( 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. @@ -396,7 +445,10 @@ def resolve_question_extras( 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 + 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 diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index db3be90..2c66782 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -102,10 +102,9 @@ "invalid_question_variations": ( "Invalid question variations. Pass up to 10 non-empty alternate phrasings." ), - "invalid_question_logic": ( - "Invalid question logic. Check the operators, actions, connectors, and jump " - "targets — see `dailybot checkin questions edit --help`." - ), + # 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." diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 8e62109..ffa417a 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -215,7 +215,7 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | `form questions add --type --question [--options] [--required/--optional] [--blocker] [extras]` | `POST /v1/forms//questions/` | `multiple_choice` requires `--options`; `boolean` takes none. `--blocker` tags the blocker question. Extras below. | | `form questions edit [... --blocker/--no-blocker] [extras]` | `PATCH /v1/forms//questions//` | Partial update (non-destructive). | -**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 ` (a JSON `{"rules": {"rules_if": [...], "rules_else": {...}}}` object) or the inline single-jump shortcut `--jump-if-equals VALUE --jump-to N` (`N` = target question index, `-1` = end). Logic operators: `is_equal_to`, `is_not_equal_to`, `contains`, `not_contains`; actions: `jump_to` (int target), `trigger_checkin` / `trigger_form` (UUID target); connectors: `and`, `or`. 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 ` (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_to`, `greater_than`, `greater_or_equal_to`; `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 ` | `DELETE /v1/forms//questions//delete/` | Confirms unless `--yes`. | | `form questions reorder ...` | `PUT /v1/forms//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. | diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index bacb681..2662edf 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -126,10 +126,57 @@ def test_inline_jump_logic_built(self) -> None: ], "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) @@ -268,7 +315,8 @@ def test_extras_read_from_file(self, tmp_path: Path) -> None: ], "then": {"action": "jump_to", "target": 2}, } - ] + ], + "rules_else": {"action": "jump_to", "target": -1}, } }, } diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py index 309b691..4736cae 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -157,7 +157,8 @@ def test_add_with_logic_file(self, runner: CliRunner, tmp_path: Any) -> None: logic_file.write_text( '{"rules": {"rules_if": [{"conditions": [{"operator": "is_equal_to", ' '"comparison_value": "No"}], "then": {"action": "trigger_form", ' - '"target": "other-form"}}]}}' + '"target": "other-form"}}], "rules_else": {"action": "jump_to", ' + '"target": -1}}}' ) with _auth(), _client() as cls: client: MagicMock = cls.return_value From bdc0a7e952732823f9714a61d901f645992cd49f Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 15:57:57 +0000 Subject: [PATCH 26/35] fix(forms,checkin): correct numeric logic operator spelling (_than suffix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The deployed logic engine spells the numeric comparison operators with a `_than` suffix, not `_to`. Align the CLI's operator whitelist and docs so valid numeric comparators aren't wrongly rejected client-side. ## Change Log - LOGIC_OPERATORS: lower_or_equal_to → lower_or_equal_than, greater_or_equal_to → greater_or_equal_than - API_REFERENCE: same correction in the per-type operator list ## Verification Live-tested against a local org (extended operators now deployed): begins_with on a text question and greater_or_equal_than on a numeric question both apply; delete now auto-clamps dangling forward-jump targets to -1 instead of failing. ## Risks - None — corrects two operator spellings to match the server. Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/authoring_helpers.py | 4 ++-- docs/API_REFERENCE.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 3cddada..a6ac8ea 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -42,9 +42,9 @@ "ends_with", "not_ends_with", "lower_than", - "lower_or_equal_to", + "lower_or_equal_than", "greater_than", - "greater_or_equal_to", + "greater_or_equal_than", ) LOGIC_ACTIONS: tuple[str, ...] = ("jump_to", "trigger_checkin", "trigger_form") LOGIC_CONNECTORS: tuple[str, ...] = ("and", "or") diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index ffa417a..d475109 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -215,7 +215,7 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | `form questions add --type --question [--options] [--required/--optional] [--blocker] [extras]` | `POST /v1/forms//questions/` | `multiple_choice` requires `--options`; `boolean` takes none. `--blocker` tags the blocker question. Extras below. | | `form questions edit [... --blocker/--no-blocker] [extras]` | `PATCH /v1/forms//questions//` | Partial update (non-destructive). | -**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 ` (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_to`, `greater_than`, `greater_or_equal_to`; `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 ` (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 ` | `DELETE /v1/forms//questions//delete/` | Confirms unless `--yes`. | | `form questions reorder ...` | `PUT /v1/forms//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. | From 423668bc201d670131176328bb6790f5b8f74a43 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 23:34:03 +0000 Subject: [PATCH 27/35] fix(checkin): friendly error for an unresolvable --user/--team participant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Passing a --user/--team that doesn't resolve (e.g. an email, a typo) crashed `checkin create`/`config` with an uncaught ValueError traceback from the shared resolver. Translate it to a friendly AuthoringError like every other command. ## Change Log - parse_participants: wrap the resolver calls and re-raise ValueError as AuthoringError (renders via print_error to stderr, exits non-zero) - test: unknown participant raises AuthoringError, not a traceback ## Verification Live-tested: `checkin create --user nobody@example.com` now prints `Error: No user found matching "nobody@example.com".` instead of a stacktrace. Full end-to-end re-verified against a local org (create + config + questions with extras + logic on check-ins and forms). ## Risks - None — pure error-path hardening. Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/commands/authoring_helpers.py | 25 +++++++++++++--------- tests/authoring_helpers_test.py | 8 +++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index a6ac8ea..bb98063 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -837,14 +837,19 @@ def parse_participants( ambiguous or unknown names raise the same friendly errors as other commands. """ participants: dict[str, Any] = {} - if users: - directory: list[dict[str, Any]] = client.list_users() - 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 - ] + try: + if users: + directory: list[dict[str, Any]] = client.list_users() + 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/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index 2662edf..4eb9816 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -490,6 +490,14 @@ def test_empty_groups_omitted(self) -> None: 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: From db869b10e12d25a3c32651f940402a80b17d8ebf Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 5 Jul 2026 23:57:38 +0000 Subject: [PATCH 28/35] feat(forms,checkin): require an explicit short_question unless --ai-short-question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary AI report-title generation is a frontend UX convenience — and, when intelligence is on, an async job overwrites client titles. So from the CLI, treat short_question as effectively mandatory when authoring questions: require it, or make the caller opt into AI generation explicitly. ## Change Log - New --ai-short-question flag on `questions add` and `create` (checkin + form) - require_short_question / require_short_questions helpers enforce a report title on single add and on every seeded question in a bulk create (naming the offenders), skipped only when --ai-short-question is set - interactive builder prompts for the report title per question (re-asks on blank) unless --ai-short-question - docs: API_REFERENCE, README, checkin/forms SKILLs note the requirement + opt-out - tests: +8 (helper unit tests, add/create rejection, ai opt-in, interactive prompt, updated existing fixtures to supply a title or opt in) ## Verification Live-tested against a local org: add/create without a title now fails fast with a clear message (bulk names the offending question); --short-question and --ai-short-question both succeed. ## Risks - Behavior change: authoring a question now requires --short-question or --ai-short-question. Intentional per product direction; edit is unaffected. Co-Authored-By: Claude Opus 4.8 --- .agents/skills/dailybot/checkin/SKILL.md | 4 +- .agents/skills/dailybot/forms/SKILL.md | 5 +- README.md | 3 ++ dailybot_cli/commands/authoring_helpers.py | 57 +++++++++++++++++++++- dailybot_cli/commands/checkin.py | 30 ++++++++++-- dailybot_cli/commands/form.py | 35 ++++++++++--- docs/API_REFERENCE.md | 2 + tests/authoring_helpers_test.py | 24 +++++++++ tests/authoring_lifecycle_test.py | 30 ++++++++++-- tests/authoring_security_test.py | 1 + tests/checkin_authoring_test.py | 15 ++++++ tests/form_authoring_test.py | 53 +++++++++++++++++++- tests/interactive_builder_test.py | 37 +++++++++++++- 13 files changed, 276 insertions(+), 20 deletions(-) diff --git a/.agents/skills/dailybot/checkin/SKILL.md b/.agents/skills/dailybot/checkin/SKILL.md index 5375bb0..1c2b967 100644 --- a/.agents/skills/dailybot/checkin/SKILL.md +++ b/.agents/skills/dailybot/checkin/SKILL.md @@ -323,7 +323,9 @@ forms: `text`, `multiple_choice` (needs `--options`), `boolean` (no options), `--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). -Empty question text is rejected server-side. +**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 diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index 92edbd8..08e0567 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -573,7 +573,10 @@ may be tagged the **blocker** question with `--blocker` (or `"is_blocker": true` 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`. Empty question text is rejected server-side. +`--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 diff --git a/README.md b/README.md index 1970d73..0438979 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,9 @@ dailybot checkin questions reorder > `--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) diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index bb98063..cb49428 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -262,6 +262,44 @@ def validate_short_question(text: str) -> str: 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. @@ -727,11 +765,14 @@ def build_checkin_config( return config -def build_questions_interactively() -> list[dict[str, Any]]: +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. + 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( @@ -757,6 +798,17 @@ def build_questions_interactively() -> list[dict[str, Any]]: 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( @@ -766,6 +818,7 @@ def build_questions_interactively() -> list[dict[str, Any]]: options=options, required=bool(required), is_blocker=bool(is_blocker), + short_question=short_question, ) ) except AuthoringError as exc: diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index fa5e3b7..52243a7 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -19,6 +19,8 @@ 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 ( @@ -405,6 +407,13 @@ def checkin_edit( 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( @@ -418,6 +427,7 @@ def checkin_create( questions_file: str | None, interactive: bool, report_channels: tuple[str, ...], + ai_short_question: bool, json_mode: bool, **config_flags: Any, ) -> None: @@ -456,11 +466,13 @@ def checkin_create( "Add --user and/or --team." ) if interactive: - questions: list[dict[str, Any]] | None = build_questions_interactively() + 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( @@ -620,6 +632,12 @@ def checkin_questions() -> None: 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, @@ -628,22 +646,28 @@ def checkin_questions_add( 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?" + --question "What are you working on today?" --short-question "Focus" dailybot checkin questions add --type boolean \\ - --question "Any blockers?" --blocker + --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, diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index c767fef..e32be91 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -12,6 +12,8 @@ parse_options, parse_questions_file, question_extras_options, + require_short_question, + require_short_questions, resolve_question_extras, ) from dailybot_cli.commands.public_api_helpers import ( @@ -429,12 +431,20 @@ def form_delete( 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.", +) @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, ) -> None: """Create a form (optionally seeded with questions). @@ -442,7 +452,8 @@ def form_create( \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`. + `dailybot form questions add`. Each seeded question needs a report title + (--short-question / "short_question") unless you pass --ai-short-question. \b Examples: @@ -453,11 +464,13 @@ def form_create( """ client = require_auth() if interactive: - questions: list[dict[str, Any]] | None = build_questions_interactively() + 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 form..."): result: dict[str, Any] = client.create_form( @@ -594,6 +607,12 @@ def form_questions_list(form_uuid: str, json_mode: bool) -> None: 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, @@ -602,24 +621,28 @@ def form_questions_add( 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 - Extras: --short-question (report title), --variation (repeatable), and logic - via --logic-file or inline --jump-if-equals/--jump-to. + 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?" + 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" + --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, diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index d475109..22f8e73 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -215,6 +215,8 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | `form questions add --type --question [--options] [--required/--optional] [--blocker] [extras]` | `POST /v1/forms//questions/` | `multiple_choice` requires `--options`; `boolean` takes none. `--blocker` tags the blocker question. Extras below. | | `form questions edit [... --blocker/--no-blocker] [extras]` | `PATCH /v1/forms//questions//` | 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 ` (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 ` | `DELETE /v1/forms//questions//delete/` | Confirms unless `--yes`. | | `form questions reorder ...` | `PUT /v1/forms//questions/reorder/` | Unknown UUID → 400. | diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index 4eb9816..c846452 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -20,6 +20,8 @@ parse_participants, parse_questions_file, parse_schedule, + require_short_question, + require_short_questions, resolve_question_extras, validate_logic, validate_short_question, @@ -262,6 +264,28 @@ def test_build_question_carries_extras(self) -> None: 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: diff --git a/tests/authoring_lifecycle_test.py b/tests/authoring_lifecycle_test.py index 9ce4cd0..15f91e9 100644 --- a/tests/authoring_lifecycle_test.py +++ b/tests/authoring_lifecycle_test.py @@ -32,7 +32,9 @@ def _client() -> Any: 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?"}])) + qfile.write_text( + json.dumps([{"question_type": "text", "question": "Well?", "short_question": "Wins"}]) + ) form: dict[str, Any] = { "id": "f-1", "name": "Retro", @@ -55,7 +57,18 @@ def test_full_form_authoring_flow(self, runner: CliRunner, tmp_path: Any) -> Non steps: list[list[str]] = [ ["channels", "list"], ["form", "create", "-n", "Retro", "--questions-file", str(qfile)], - ["form", "questions", "add", "f-1", "--type", "text", "--question", "Rating?"], + [ + "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"], @@ -105,7 +118,18 @@ def test_full_checkin_authoring_flow(self, runner: CliRunner) -> None: "--team", "Eng", ], - ["checkin", "questions", "add", "fu-1", "--type", "text", "--question", "Today?"], + [ + "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"], diff --git a/tests/authoring_security_test.py b/tests/authoring_security_test.py index 9ec75f8..331dec8 100644 --- a/tests/authoring_security_test.py +++ b/tests/authoring_security_test.py @@ -87,6 +87,7 @@ def test_invalid_question_type_never_calls_client(self, runner: CliRunner) -> No "stars", "--question", "How many?", + "--ai-short-question", ], ) cls.return_value.add_form_question.assert_not_called() diff --git a/tests/checkin_authoring_test.py b/tests/checkin_authoring_test.py index 2113120..ecdac10 100644 --- a/tests/checkin_authoring_test.py +++ b/tests/checkin_authoring_test.py @@ -284,6 +284,7 @@ def test_add(self, runner: CliRunner) -> None: "boolean", "--question", "Blockers?", + "--ai-short-question", ], ) assert result.exit_code == 0 @@ -305,6 +306,7 @@ def test_add_with_blocker(self, runner: CliRunner) -> None: "--question", "Any blockers?", "--blocker", + "--ai-short-question", ], ) assert result.exit_code == 0 @@ -357,6 +359,7 @@ def test_add_with_inline_jump_logic(self, runner: CliRunner) -> None: "No", "--jump-to", "-1", + "--ai-short-question", ], ) assert result.exit_code == 0 @@ -380,12 +383,24 @@ def test_add_jump_to_without_value_fails_fast(self, runner: CliRunner) -> None: "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 diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py index 4736cae..f7bcfa9 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -42,7 +42,9 @@ def test_create_minimal(self, runner: CliRunner) -> None: 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?"}])) + 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 @@ -53,6 +55,41 @@ def test_create_with_questions_file(self, runner: CliRunner, tmp_path: Any) -> N 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 + def test_create_with_report_channel_inline(self, runner: CliRunner) -> None: with _auth(), _client() as cls: client: MagicMock = cls.return_value @@ -147,7 +184,17 @@ def test_add_text(self, runner: CliRunner) -> None: client.add_form_question.return_value = {"uuid": "q-new", "question": "New?"} result = runner.invoke( cli, - ["form", "questions", "add", "form-uuid", "--type", "text", "--question", "New?"], + [ + "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" @@ -201,6 +248,7 @@ def test_add_with_blocker(self, runner: CliRunner) -> None: "--question", "Blocked?", "--blocker", + "--ai-short-question", ], ) assert result.exit_code == 0 @@ -219,6 +267,7 @@ def test_add_multiple_choice_needs_options(self, runner: CliRunner) -> None: "multiple_choice", "--question", "Rating?", + "--ai-short-question", ], ) cls.return_value.add_form_question.assert_not_called() diff --git a/tests/interactive_builder_test.py b/tests/interactive_builder_test.py index fa408e0..5e6cfd5 100644 --- a/tests/interactive_builder_test.py +++ b/tests/interactive_builder_test.py @@ -31,10 +31,13 @@ class TestBuildQuestionsInteractively: 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? @@ -49,8 +52,26 @@ def test_builds_two_questions(self, mock_q: MagicMock, _isatty: MagicMock) -> No 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") @@ -79,7 +100,9 @@ def test_form_create_interactive( 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"]) + 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?"}] @@ -100,7 +123,17 @@ def test_checkin_create_interactive( client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] result = runner.invoke( - cli, ["checkin", "create", "-n", "Standup", "--interactive", "--team", "Eng"] + cli, + [ + "checkin", + "create", + "-n", + "Standup", + "--interactive", + "--team", + "Eng", + "--ai-short-question", + ], ) assert result.exit_code == 0 mock_builder.assert_called_once() From 5bffa88fcb655e3e42b1984dbaf744fcc87cca47 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Mon, 6 Jul 2026 00:21:21 +0000 Subject: [PATCH 29/35] feat(forms,checkin): forward generate_short_question opt-in + map short_question_required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The API now validates that a report title is present on create/add (unless opted in) and preserves explicit titles from the AI job. Wire the CLI's --ai-short-question to the server's `generate_short_question` opt-in and map the new error code. ## Change Log - api_client.create_checkin / create_form: new generate_short_question param sent as a top-level request field when True - checkin/form create: forward generate_short_question=ai_short_question - checkin/form questions add: set generate_short_question on the question payload when --ai-short-question is passed - map short_question_required -> friendly message (suggests --short-question / --ai-short-question) - tests: +5 (client sends/omits the field, ai opt-in forwarded, error mapping) ## Verification Live-tested against a local org: server now returns 400 short_question_required for a titleless question without opt-in; --ai-short-question sends the opt-in and the create succeeds. Explicit titles are preserved by the AI job on the create path (add-single preservation is still pending server-side; reported separately). ## Risks - None — additive field, only sent when opted in. Co-Authored-By: Claude Opus 4.8 --- dailybot_cli/api_client.py | 14 ++++++++++- dailybot_cli/commands/checkin.py | 3 +++ dailybot_cli/commands/form.py | 3 +++ dailybot_cli/commands/public_api_helpers.py | 4 +++ tests/api_client_test.py | 22 ++++++++++++++++ tests/checkin_authoring_test.py | 5 +++- tests/form_authoring_test.py | 28 +++++++++++++++++++++ 7 files changed, 77 insertions(+), 2 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index b5d0892..3b9382e 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -423,11 +423,14 @@ def create_checkin( 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: @@ -438,6 +441,8 @@ def create_checkin( 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( @@ -759,13 +764,20 @@ def create_form( questions: list[dict[str, Any]] | None = None, *, report_channels: list[str] | None = None, + generate_short_question: bool = False, ) -> dict[str, Any]: - """POST /v1/forms/create/ — create a form with optional questions + channels.""" + """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``. + """ 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 response: httpx.Response = httpx.post( f"{self.api_url}/v1/forms/create/", json=payload, diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 52243a7..9a752ca 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -482,6 +482,7 @@ def checkin_create( 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) @@ -677,6 +678,8 @@ def checkin_questions_add( 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) diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index e32be91..512b218 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -477,6 +477,7 @@ def form_create( name, questions, report_channels=list(report_channels) if report_channels else None, + generate_short_question=ai_short_question, ) except APIError as exc: exit_for_api_error(exc, json_mode) @@ -652,6 +653,8 @@ def form_questions_add( 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) diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 2c66782..4a0862a 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -75,6 +75,10 @@ "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)."), diff --git a/tests/api_client_test.py b/tests/api_client_test.py index fa2d502..0301486 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -1108,6 +1108,28 @@ def test_create_checkin_merges_config(self, client: DailyBotClient) -> None: "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 diff --git a/tests/checkin_authoring_test.py b/tests/checkin_authoring_test.py index ecdac10..77df3df 100644 --- a/tests/checkin_authoring_test.py +++ b/tests/checkin_authoring_test.py @@ -288,7 +288,10 @@ def test_add(self, runner: CliRunner) -> None: ], ) assert result.exit_code == 0 - assert client.add_checkin_question.call_args[0][1]["question_type"] == "boolean" + 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: diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py index f7bcfa9..9766017 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -89,6 +89,34 @@ def test_create_questions_file_ai_short_question_allows_missing( ], ) 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: From cf2f4d7dc093786d6c98aa4e1d80661944583b9e Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano <xergioalex@gmail.com> Date: Mon, 6 Jul 2026 01:37:53 +0000 Subject: [PATCH 30/35] =?UTF-8?q?feat(forms):=20full=20form=20config=20aut?= =?UTF-8?q?horing=20=E2=80=94=20workflow,=20permissions,=20approval,=20com?= =?UTF-8?q?mand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The API brought form create/config to web parity (workflow states, three permission audiences, anonymous/public/approval settings, ChatOps command; unknown-field rejection). Add the CLI surface: a new `form config` command + config flags on `form create`, mirroring `checkin config`. ## Change Log - authoring_helpers: build_form_config, parse_workflow_states, build_workflow, build_form_audience (name/UUID resolution), validate_command, and resolve_form_config orchestrator; new constants (FORM_PERMISSION_MODES, WORKFLOW_MAX_STATES, patterns) - api_client: create_form / update_form_config accept a `config` dict merged inline - form.py: shared _form_config_flag_options decorator; new `form config` command; config flags added to `form create` (--state, --can-edit/see/change-states, --anonymous/public/brand/require-identity, --approval/--approver-*, --command, etc.) - display: print_form_detail rewritten for the canonical nested shape — renders workflow states, permission audiences, anonymous/public/approval, and command - error mapping: workflow_requires_states, invalid_workflow_state, invalid_permission_audience, invalid_approvers, invalid_command, command_already_exists, form_name_too_short - docs: API_REFERENCE, README, forms SKILL - tests: +30 (config builder, workflow/audience/command helpers, create/config flag forwarding, validations, error mapping, client config merge, detail render) ## Verification Live-tested against a local org: full-config create round-trips (workflow, anonymous, public, identity, command, permissions); `form config` applies partial updates (restricted audience with team resolution, --no-workflow, --no-command); client-side validations fail fast (bad color, restricted without who, bad command charset). ## Risks - None for backward compat — `form edit` unchanged; all new flags optional. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .agents/skills/dailybot/forms/SKILL.md | 7 + README.md | 8 + dailybot_cli/api_client.py | 16 +- dailybot_cli/commands/authoring_helpers.py | 211 ++++++++++++++++++++ dailybot_cli/commands/form.py | 165 ++++++++++++++- dailybot_cli/commands/public_api_helpers.py | 21 ++ dailybot_cli/display.py | 71 +++++-- docs/API_REFERENCE.md | 9 +- tests/api_client_test.py | 24 +++ tests/authoring_helpers_test.py | 140 +++++++++++++ tests/form_authoring_test.py | 83 ++++++++ 11 files changed, 730 insertions(+), 25 deletions(-) diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index 08e0567..2812508 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -538,6 +538,13 @@ dailybot form list --include-archived # includes archived, flagged in a Statu # Edit config / archive (soft-delete). Note: `form archive` is the definition; # `form delete` still removes a *response*. dailybot form edit <form_uuid> --name "Updated Retro" --report-channel <channel_uuid> +# Full config (superset of `form edit`) — mirrors the web Setup tab: +dailybot form config <form_uuid> --state "Draft:#ccc" --state "Released:#2ecc71" # workflow +dailybot form config <form_uuid> --anonymous --public --require-identity --brand +dailybot form config <form_uuid> --can-edit owner_and_admins --can-see restricted --can-see-team "Eng" +dailybot form config <form_uuid> --approval --approver-user "Jane Doe" +dailybot form config <form_uuid> --command release # ChatOps: @dailybot release +dailybot form config <form_uuid> --no-workflow --no-command --inactive dailybot form archive <form_uuid> # Manage questions (--blocker tags the blocker question) diff --git a/README.md b/README.md index 0438979..47ac097 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,14 @@ dailybot form questions edit <form_uuid> <question_uuid> --question "Reworded?" dailybot form questions delete <form_uuid> <question_uuid> dailybot form questions reorder <form_uuid> <q3> <q1> <q2> +# 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 <channel_uuid> +dailybot form config <form_uuid> --anonymous --public --require-identity +dailybot form config <form_uuid> --can-see restricted --can-see-team "Engineering" +dailybot form config <form_uuid> --approval --approver-user "Jane Doe" +dailybot form config <form_uuid> --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 diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 3b9382e..d238a22 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -765,11 +765,14 @@ def create_form( *, 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``. + 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: @@ -778,6 +781,8 @@ def create_form( 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, @@ -792,13 +797,20 @@ def update_form_config( *, name: str | None = None, report_channels: list[str] | None = None, + config: dict[str, Any] | None = None, ) -> dict[str, Any]: - """PATCH /v1/forms/<form_uuid>/config/ — edit name and/or report channels.""" + """PATCH /v1/forms/<form_uuid>/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, diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index cb49428..203051d 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -84,6 +84,14 @@ _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. @@ -765,6 +773,209 @@ def build_checkin_config( 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) + audience: dict[str, Any] = {"mode": "restricted"} + audience.update(resolved) + return audience + 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, ...] = (), + 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.") + approvers: dict[str, Any] | None = None + if approver_users or approver_teams: + approvers = parse_participants(approver_users, approver_teams, client) + 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. diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 512b218..5198704 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -1,5 +1,6 @@ """Form commands for the user-scoped public API.""" +from collections.abc import Callable from typing import Any import click @@ -14,6 +15,7 @@ question_extras_options, require_short_question, require_short_questions, + resolve_form_config, resolve_question_extras, ) from dailybot_cli.commands.public_api_helpers import ( @@ -52,6 +54,98 @@ 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 or UUID)." + ), + click.option( + "--approver-team", "approver_teams", multiple=True, help="Approver team (name or UUID)." + ), + 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. @@ -438,6 +532,7 @@ def form_delete( 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, @@ -446,21 +541,24 @@ def form_create( report_channels: tuple[str, ...], ai_short_question: bool, json_mode: bool, + **config_flags: Any, ) -> None: - """Create a form (optionally seeded with questions). + """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. + (--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 "Retro" --interactive - dailybot form create -n "Retro" --report-channel <channel_uuid> --json + dailybot form create -n "Release" --state "Draft:#ccc" --state "Done:#2ecc71" \\ + --command release --can-edit owner_and_admins --report-channel <channel_uuid> """ client = require_auth() if interactive: @@ -471,6 +569,7 @@ def form_create( 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( @@ -478,6 +577,7 @@ def form_create( 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) @@ -531,6 +631,63 @@ def form_edit( 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 <form_uuid> --inactive --command release + dailybot form config <form_uuid> --state "Draft:#ccc" --state "Done:#2ecc71" + dailybot form config <form_uuid> --anonymous --public --require-identity + dailybot form config <form_uuid> --can-see restricted --can-see-team "Eng" + dailybot form config <form_uuid> --approval --approver-user "Jane Doe" + """ + client = require_auth() + 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.") diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 4a0862a..2937889 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -101,6 +101,27 @@ "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.", "invalid_question_variations": ( diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index d27c3df..b5c1562 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -702,42 +702,81 @@ 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("is_archived"): - table.add_row("Status", "[yellow]archived[/yellow]") + 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)) + 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")) _print_attached_channels(form_data.get("report_channels") or []) - if workflow_enabled: + 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", "")), diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 22f8e73..5f9f5fc 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -208,8 +208,11 @@ Creating and configuring forms/check-ins (as opposed to filling them in). All au | 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]` | `POST /v1/forms/create/` (`name`, `questions?`, `report_channels?` all inline) | Question types: `text`, `multiple_choice`, `boolean`, `numeric`; ≤ 50. | -| `form edit <uuid> [--name] [--report-channel]` | `PATCH /v1/forms/<uuid>/config/` | | +| `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. | @@ -499,7 +502,7 @@ key, so all of these commands work with `DAILYBOT_API_KEY` set even without | `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? }` | Form | `form edit` | +| `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` | diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 0301486..c7c7271 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -924,6 +924,30 @@ def test_create_form_omits_empty_questions(self, client: DailyBotClient) -> None 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 diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index c846452..aa7d32b 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -12,17 +12,23 @@ 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, ) @@ -500,6 +506,110 @@ def test_malformed_cron_rejected(self) -> None: 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", "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", "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) + + class TestParseParticipants: def test_resolves_users_and_teams(self) -> None: client: MagicMock = MagicMock() @@ -737,3 +847,33 @@ def test_forms_table_shows_archived_status(self) -> None: ) 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/form_authoring_test.py b/tests/form_authoring_test.py index 9766017..9c192c2 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -174,6 +174,89 @@ def test_edit_requires_a_field(self, runner: CliRunner) -> None: 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", "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_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_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: From 218b4a6d6fa932d3a11a2a007c2132b9361e2bb7 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano <xergioalex@gmail.com> Date: Mon, 6 Jul 2026 01:48:01 +0000 Subject: [PATCH 31/35] fix(client): question reorder sent the wrong field name (silent no-op) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary reorder_form_questions and reorder_checkin_questions sent {"order": [...]}, but the server expects {"question_uuids": [...]}. The unknown "order" field was silently ignored and the endpoint returned 200 with the unchanged list, so the CLI reported "reordered" while nothing actually moved. Send the correct field name. ## Change Log - api_client: reorder_form_questions / reorder_checkin_questions now send {"question_uuids": order} - tests: updated the two reorder payload assertions ## Verification Live-tested against a local org: reordering form and check-in questions now actually changes the stored order (previously a silent no-op). ## Risks - None — corrects the request field so reorder finally takes effect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- dailybot_cli/api_client.py | 4 ++-- tests/api_client_test.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index d238a22..d812d16 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -552,7 +552,7 @@ def reorder_checkin_questions( """PUT /v1/checkins/<followup_uuid>/questions/reorder/ — set a new question order.""" response: httpx.Response = httpx.put( f"{self.api_url}/v1/checkins/{followup_uuid}/questions/reorder/", - json={"order": order}, + json={"question_uuids": order}, headers=self._headers(), timeout=self.timeout, ) @@ -880,7 +880,7 @@ def reorder_form_questions( """PUT /v1/forms/<form_uuid>/questions/reorder/ — set a new question order.""" response: httpx.Response = httpx.put( f"{self.api_url}/v1/forms/{form_uuid}/questions/reorder/", - json={"order": order}, + json={"question_uuids": order}, headers=self._headers(), timeout=self.timeout, ) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index c7c7271..4290bdf 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -1039,7 +1039,7 @@ def test_reorder_form_questions(self, client: DailyBotClient) -> None: 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"] == {"order": ["q2", "q1"]} + 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) @@ -1257,7 +1257,7 @@ def test_reorder_checkin_questions(self, client: DailyBotClient) -> None: 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"] == {"order": ["q2", "q1"]} + 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) From 7c1ce87b706377adbe1dde620913831b428dd238 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano <xergioalex@gmail.com> Date: Mon, 6 Jul 2026 02:23:39 +0000 Subject: [PATCH 32/35] feat(forms,checkin): map reorder validation codes + explicit full-replace audiences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The API hardened the reorder endpoints (reject unknown/missing/incomplete/duplicate payloads) and confirmed full-replace semantics for form approvers/permission audiences. Map the new error codes and send audiences/approvers as complete objects. ## Change Log - error mapping: question_uuids_required, question_uuids_incomplete, question_uuids_duplicate (unknown_field / question_not_found already mapped) - build_form_audience / resolve_form_config approvers: emit both user_uuids and team_uuids explicitly (empty where unset) so an edit is an unambiguous replace, matching the server's Option A semantics and report_channels - tests: reorder incomplete-set error mapping; audience assertions updated to the explicit both-keys shape ## Verification Live-tested against a local org: reorder now rejects a wrong field ({order} -> unknown_field) and an incomplete set (question_uuids_incomplete), and applies a full valid set; setting a restricted audience to a user clears the previously-set team (clean replace). ## Risks - None — additive error mappings; the explicit both-keys payload is semantically identical to before under the server's replace rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- dailybot_cli/commands/authoring_helpers.py | 17 +++++++++++++---- dailybot_cli/commands/public_api_helpers.py | 6 ++++++ tests/authoring_helpers_test.py | 8 ++++++-- tests/form_authoring_test.py | 18 +++++++++++++++++- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 203051d..a3430a8 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -838,9 +838,13 @@ def build_form_audience( "(those imply 'restricted')." ) resolved: dict[str, Any] = parse_participants(users, teams, client) - audience: dict[str, Any] = {"mode": "restricted"} - audience.update(resolved) - return audience + # 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}") @@ -951,7 +955,12 @@ def resolve_form_config( raise AuthoringError("Pass either --command NAME or --no-command, not both.") approvers: dict[str, Any] | None = None if approver_users or approver_teams: - approvers = parse_participants(approver_users, approver_teams, client) + 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, diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 2937889..470c7c1 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -124,6 +124,12 @@ ), "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." ), diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index aa7d32b..53cedf2 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -573,7 +573,7 @@ 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", "team_uuids": ["t-1"]} + assert result == {"mode": "restricted", "user_uuids": [], "team_uuids": ["t-1"]} def test_audience_restricted_without_who_rejected(self) -> None: client: MagicMock = MagicMock() @@ -601,7 +601,11 @@ def test_resolve_form_config_full(self) -> None: 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", "team_uuids": ["t-1"]} + 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: diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py index 9c192c2..7327d8f 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -208,7 +208,11 @@ def test_create_forwards_full_config(self, runner: CliRunner) -> None: 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", "team_uuids": ["t-1"]} + 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: @@ -420,6 +424,18 @@ def test_reorder(self, runner: CliRunner) -> None: 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: From e23b06cbd5811786b9d3a8c3fc1916a5b1da8f5d Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano <xergioalex@gmail.com> Date: Mon, 6 Jul 2026 03:01:27 +0000 Subject: [PATCH 33/35] feat(forms,checkin): surface public_url + enforce the 3 report-channel cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The API now echoes a form's public share URL and enforces a 3 report-channel cap. Render the URL and fail fast client-side on too many channels. ## Change Log - display: form panel + create/update summary show public_url when present - authoring_helpers: MAX_REPORT_CHANNELS=3 + check_report_channels(); called on form create/edit/config and checkin create/config - error mapping: too_many_report_channels - tests: +3 (channel cap fail-fast, public_url shown after config, cap on form config) ## Verification Live-tested: public_url renders after `form config --public` and in `form get`; 4 channels fail fast client-side ("limit is 3") and the server rejects them too (too_many_report_channels). ## Risks - None — additive rendering + a fail-fast that mirrors the server cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- dailybot_cli/commands/authoring_helpers.py | 14 ++++++++ dailybot_cli/commands/checkin.py | 3 ++ dailybot_cli/commands/form.py | 4 +++ dailybot_cli/commands/public_api_helpers.py | 1 + dailybot_cli/display.py | 13 ++++---- tests/form_authoring_test.py | 36 +++++++++++++++++++++ 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index a3430a8..6060cd9 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -24,6 +24,8 @@ 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 @@ -157,6 +159,18 @@ def question_extras_options(func: Any) -> Any: 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: diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 9a752ca..903d6d4 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -13,6 +13,7 @@ build_question, build_question_edit_fields, build_questions_interactively, + check_report_channels, parse_options, parse_participants, parse_questions_file, @@ -453,6 +454,7 @@ def checkin_create( 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 ) @@ -539,6 +541,7 @@ def checkin_config( dailybot checkin config <followup_uuid> --reminders 3 --reminder-interval 30 dailybot checkin config <followup_uuid> --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 ( diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 5198704..05f1fe7 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -10,6 +10,7 @@ build_question, build_question_edit_fields, build_questions_interactively, + check_report_channels, parse_options, parse_questions_file, question_extras_options, @@ -561,6 +562,7 @@ def form_create( --command release --can-edit owner_and_admins --report-channel <channel_uuid> """ 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: @@ -614,6 +616,7 @@ def form_edit( 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( @@ -665,6 +668,7 @@ def form_config( dailybot form config <form_uuid> --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( diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 470c7c1..f38ecae 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -144,6 +144,7 @@ "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." diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index b5c1562..af8235e 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -756,6 +756,8 @@ def print_form_detail(form_data: dict[str, Any]) -> None: 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"), @@ -1070,13 +1072,10 @@ def print_form_created(form: dict[str, Any], *, updated: bool = False) -> None: 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" - console.print( - Panel( - f"[bold]{name}[/bold]\nID: {form_id}", - title=f"[bold]{title}[/bold]", - border_style="green", - ) - ) + 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)) diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py index 7327d8f..2b64242 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -238,6 +238,42 @@ def test_config_bad_state_color_fails_fast(self, runner: CliRunner) -> None: 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 From f894dd944cea2c6d477327cc03e81c0ea3aaa55e Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano <xergioalex@gmail.com> Date: Mon, 6 Jul 2026 03:10:36 +0000 Subject: [PATCH 34/35] feat(forms,checkin): resolve users by email + --no-approvers to clear approvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Two CLI ergonomics improvements: the user resolver now matches an email (in addition to name/UUID), and forms gain --no-approvers to clear the approver list. ## Change Log - resolve_user_by_name_or_uuid: exact email match when the identifier contains "@" (falls back to a clear "no user with email" error) - form config/create: --no-approvers sends approvers {user_uuids:[], team_uuids:[]} (conflicts with --approver-user/-team) - help text: --user / --approver-user mention email - tests: +5 (email resolution, no-approvers clear + conflict, email approver forwarding) ## Verification Live-tested: --no-approvers clears the list. NOTE: email resolution is functional client-side but the current /v1/users/ directory does not return an `email` field, so email lookups can't match until the API adds it (raised separately) — the code is correct and future-proof (unit-tested against a directory that includes email). ## Risks - None — additive; name/UUID resolution unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- dailybot_cli/commands/authoring_helpers.py | 9 +++++++- dailybot_cli/commands/checkin.py | 8 +++++-- dailybot_cli/commands/form.py | 8 ++++++- dailybot_cli/commands/public_api_helpers.py | 12 ++++++++++- tests/authoring_helpers_test.py | 19 +++++++++++++++++ tests/form_authoring_test.py | 23 +++++++++++++++++++++ 6 files changed, 74 insertions(+), 5 deletions(-) diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py index 6060cd9..eac93cd 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -956,6 +956,7 @@ def resolve_form_config( 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]: @@ -967,8 +968,14 @@ def resolve_form_config( """ 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 approver_users or approver_teams: + 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 = { diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 903d6d4..3078081 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -394,7 +394,9 @@ def checkin_edit( @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 or UUID; repeatable).") +@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( @@ -507,7 +509,9 @@ def checkin_create( multiple=True, help="Report-channel UUID (repeatable); replaces the check-in's channels.", ) -@click.option("--user", "users", multiple=True, help="Participant user (name or UUID; repeatable).") +@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 diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 05f1fe7..2390066 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -132,11 +132,17 @@ def _form_config_flag_options(func: Callable[..., Any]) -> Callable[..., Any]: help="File new submissions for approval.", ), click.option( - "--approver-user", "approver_users", multiple=True, help="Approver (name or UUID)." + "--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." diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index f38ecae..43ecbd1 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -325,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: @@ -333,6 +333,16 @@ 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: + 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/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py index 53cedf2..fb990ee 100644 --- a/tests/authoring_helpers_test.py +++ b/tests/authoring_helpers_test.py @@ -613,6 +613,25 @@ def test_resolve_form_config_command_conflict(self) -> None: 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: diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py index 2b64242..9d1a5ec 100644 --- a/tests/form_authoring_test.py +++ b/tests/form_authoring_test.py @@ -225,6 +225,29 @@ def test_config_forwards_partial(self, runner: CliRunner) -> None: 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"]) From 3f9a2cd702ef0b2158487cfdc917734c2638bb1c Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano <xergioalex@gmail.com> Date: Mon, 6 Jul 2026 03:44:34 +0000 Subject: [PATCH 35/35] feat(client): request include_email so email resolution works (admin/manager) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Email is already available on /v1/users/ behind ?include_email=true (server-gated to admins/managers). Request it when resolving participants so --user/--approver-user/ --can-*-user accept an email. ## Change Log - api_client.list_users: include_email flag adds ?include_email=true - parse_participants: fetches the directory with include_email=True - resolver: clearer message when email lookup isn't available (member token — directory has no emails): points to name/UUID instead - tests: list_users include_email query assertion ## Verification Live-tested with an admin token: --approver-user and check-in --user now resolve a person by email end-to-end; a member token falls back to a clear "needs admin/manager permissions" message. ## Risks - None — include_email is silently ignored for non-privileged callers; name/UUID resolution unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- dailybot_cli/api_client.py | 10 ++++++++-- dailybot_cli/commands/authoring_helpers.py | 4 +++- dailybot_cli/commands/public_api_helpers.py | 7 +++++++ tests/api_client_test.py | 10 ++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index d812d16..832b58e 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -886,15 +886,21 @@ def reorder_form_questions( ) return self._handle_response(response) - def list_users(self, *, include_inactive: bool = False) -> list[dict[str, Any]]: + 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 index eac93cd..2af791c 100644 --- a/dailybot_cli/commands/authoring_helpers.py +++ b/dailybot_cli/commands/authoring_helpers.py @@ -1133,7 +1133,9 @@ def parse_participants( participants: dict[str, Any] = {} try: if users: - directory: list[dict[str, Any]] = client.list_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 ] diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 43ecbd1..a542575 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -341,6 +341,13 @@ def resolve_user_by_name_or_uuid( 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]] = [ diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 4290bdf..5173b9f 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -432,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