From 1786c1a7be55f636deddd68ab40ffdfa66e0a6ef Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Thu, 9 Jul 2026 19:01:25 +0200 Subject: [PATCH 1/2] feat: add 'deals create' command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'deals create --name … [--owner-id] [--account-id] [--amount] [--stage-id | --stage-name] [--closed-date]', wrapping the new ApolloClient.create_deal. --name is the only required field; --stage-name resolves to an ID via list_all_stages (same helper as 'deals search'), and passing both --stage-id and --stage-name is a validation error. Depends on qodev-apollo-api create_deal (apollo-api#13). CI stays red until that is released to PyPI and the version floor is bumped here. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apollo_cli/commands/deals.py | 44 ++++++++++++++++++++++++++ tests/test_commands.py | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/apollo_cli/commands/deals.py b/src/apollo_cli/commands/deals.py index bf60764..c95083b 100644 --- a/src/apollo_cli/commands/deals.py +++ b/src/apollo_cli/commands/deals.py @@ -66,6 +66,50 @@ async def get( output(deal, ctx=ctx, format_fn=format_deal_detail) +@deals_app.command +async def create( + *, + name: Annotated[str, Parameter(name="--name", help="Deal name (required)")], + owner_id: Annotated[str | None, Parameter(name="--owner-id", help="Deal owner (team member) ID")] = None, + account_id: Annotated[str | None, Parameter(name="--account-id", help="Target account/company ID")] = None, + amount: Annotated[float | None, Parameter(name="--amount", help="Deal value (no currency symbol)")] = None, + stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Deal stage ID")] = None, + stage_name: Annotated[ + str | None, + Parameter(name="--stage-name", help="Deal stage name (resolved to an ID; avoids a `pipelines stages` lookup)"), + ] = None, + closed_date: Annotated[str | None, Parameter(name="--closed-date", help="Expected close date (YYYY-MM-DD)")] = None, +) -> None: + """Create a new deal/opportunity. + + Requires a master Apollo API key (non-master keys get a 403). ``--name`` is the + only required field. Use ``--stage-name`` to set the stage by name instead of ID. + """ + if stage_id and stage_name: + error("Pass either --stage-id or --stage-name, not both.", ctx=ctx, code="conflicting_args", exit_code=2) + return + + fields: dict[str, Any] = {} + if owner_id: + fields["owner_id"] = owner_id + if account_id: + fields["account_id"] = account_id + if amount is not None: + fields["amount"] = amount + if closed_date: + fields["closed_date"] = closed_date + + async with ctx.client() as client: + if stage_name: + all_stages = await client.list_all_stages() + stage_id = resolve_stage_id(stage_name, all_stages.items, kind="deal stage") + if stage_id: + fields["opportunity_stage_id"] = stage_id + deal = await client.create_deal(name, **fields) + + output(deal, ctx=ctx, format_fn=format_deal_detail) + + ROLE_TYPE_COLUMNS = [("ID", "id"), ("Name", "name"), ("Display Order", "display_order")] diff --git a/tests/test_commands.py b/tests/test_commands.py index 945b2c6..48642e9 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -502,6 +502,60 @@ async def test_set_role_never_sends_null_role_type(self, capsys) -> None: assert all("opportunity_contact_role_type_id" not in r for r in roles) +class TestDealCreate: + @pytest.mark.asyncio + async def test_create_passes_name_and_optional_fields(self, sample_deal: dict, capsys) -> None: + """deals create forwards --name plus only the optional fields that were provided.""" + mock_client = MagicMock() + mock_client.create_deal = AsyncMock(return_value=sample_deal) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.deals import create + + await create(name="Big One", owner_id="o1", amount=5000, stage_id="st1") + + name_arg = mock_client.create_deal.call_args.args[0] + fields = mock_client.create_deal.call_args.kwargs + assert name_arg == "Big One" + assert fields == {"owner_id": "o1", "amount": 5000, "opportunity_stage_id": "st1"} + + @pytest.mark.asyncio + async def test_create_resolves_stage_name(self, sample_deal: dict, capsys) -> None: + """deals create --stage-name resolves to opportunity_stage_id.""" + mock_client = MagicMock() + mock_client.list_all_stages = AsyncMock( + return_value=MockSearchResult(items=[{"id": "st-neg", "name": "Negotiation"}], total=1, page=1) + ) + mock_client.create_deal = AsyncMock(return_value=sample_deal) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.deals import create + + await create(name="Deal", stage_name="negotiation") + + assert mock_client.create_deal.call_args.kwargs["opportunity_stage_id"] == "st-neg" + + @pytest.mark.asyncio + async def test_create_rejects_both_stage_id_and_name(self, capsys) -> None: + """Passing both --stage-id and --stage-name is a validation error; no API call is made.""" + mock_client = MagicMock() + mock_client.create_deal = AsyncMock() + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.deals import create + + with pytest.raises(SystemExit): + await create(name="Deal", stage_id="st1", stage_name="Negotiation") + + mock_client.create_deal.assert_not_called() + + class TestCustomFieldsCommand: @pytest.mark.asyncio async def test_custom_fields_list_filters_by_modality(self, capsys) -> None: From 501a24c628ccd7ac0e99682af875b782be7438a1 Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Thu, 9 Jul 2026 22:45:02 +0200 Subject: [PATCH 2/2] chore: require qodev-apollo-api>=0.4.0 for create_deal create_deal landed in apollo-api 0.4.0 (now on PyPI). Bumping the floor unblocks CI (UV_NO_SOURCES pulls from PyPI, which now has the method). Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f367f05..eac2083 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ dependencies = [ "cyclopts>=3.0", "rich>=13.0", - "qodev-apollo-api>=0.3.1", + "qodev-apollo-api>=0.4.0", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 06fe99c..057da77 100644 --- a/uv.lock +++ b/uv.lock @@ -472,7 +472,7 @@ wheels = [ [[package]] name = "qodev-apollo-api" -version = "0.3.2" +version = "0.4.0" source = { directory = "../apollo-api" } dependencies = [ { name = "httpx" },