qodev-apollo-cli 1.2.0__tar.gz → 1.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/CHANGELOG.md +15 -0
  2. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/PKG-INFO +2 -2
  3. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/pyproject.toml +2 -2
  4. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/conversations.py +4 -0
  5. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/deals.py +24 -14
  6. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/people.py +3 -3
  7. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/conversations.py +26 -19
  8. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/util.py +11 -2
  9. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/tests/test_commands.py +46 -0
  10. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/tests/test_conversations_formatter.py +18 -0
  11. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/tests/test_util.py +24 -1
  12. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/uv.lock +2 -2
  13. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/.github/workflows/ci.yml +0 -0
  14. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/.github/workflows/publish.yml +0 -0
  15. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/.gitignore +0 -0
  16. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/LICENSE +0 -0
  17. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/README.md +0 -0
  18. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/__init__.py +0 -0
  19. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/__main__.py +0 -0
  20. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/app.py +0 -0
  21. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/__init__.py +0 -0
  22. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/accounts.py +0 -0
  23. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/calls.py +0 -0
  24. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/contacts.py +0 -0
  25. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/custom_fields.py +0 -0
  26. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/emails.py +0 -0
  27. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/enrich.py +0 -0
  28. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/install.py +0 -0
  29. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/jobs.py +0 -0
  30. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/news.py +0 -0
  31. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/notes.py +0 -0
  32. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/pipelines.py +0 -0
  33. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/tasks.py +0 -0
  34. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/usage.py +0 -0
  35. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/context.py +0 -0
  36. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/__init__.py +0 -0
  37. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/accounts.py +0 -0
  38. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/contacts.py +0 -0
  39. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/deals.py +0 -0
  40. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/generic.py +0 -0
  41. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/people.py +0 -0
  42. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/help_reference.py +0 -0
  43. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/linkedin.py +0 -0
  44. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/output.py +0 -0
  45. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/SKILL.md +0 -0
  46. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/__init__.py +0 -0
  47. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/references/__init__.py +0 -0
  48. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/references/account-workflows.md +0 -0
  49. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/references/contact-workflows.md +0 -0
  50. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/references/deal-workflows.md +0 -0
  51. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/tests/conftest.py +0 -0
  52. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/tests/test_context.py +0 -0
  53. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/tests/test_install.py +0 -0
  54. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/tests/test_linkedin.py +0 -0
  55. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.2.1}/tests/test_output.py +0 -0
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.2.1] - 2026-07-08
10
+
11
+ Follow-ups from code review of the v1.2.0 changes.
12
+
13
+ ### Fixed
14
+
15
+ - **`deals set-role` no longer sends an explicit `null` role type.** Adding a contact without `--role-type` omitted the `opportunity_contact_role_type_id` key entirely instead of posting `null`, which Apollo may reject.
16
+ - **`people search` no longer drops results.** When Apollo returns both `people` and `contacts` (matched CRM records), both are now shown — previously only the first non-empty list was kept.
17
+ - **Conversation detail view is robust to raw dicts.** The participants/deals/summary/transcript sections read fields via a dict-or-model helper, so they render whether `conversations get` returns models or plain dicts (previously the sections silently rendered empty for dicts).
18
+ - **`--stage-name` errors are bounded.** An unknown stage name lists at most 15 available names (`… (+N more)`) instead of dumping the entire list.
19
+
20
+ ### Note
21
+
22
+ - `conversations search --query` maps to Apollo's universal `q_keywords` param. The conversations search endpoint is undocumented and keyword filtering hasn't been confirmed server-side; if a search returns everything unfiltered, that param is the thing to revisit.
23
+
9
24
  ## [1.2.0] - 2026-07-08
10
25
 
11
26
  Usage-driven UX improvements — every item here closes a gap where users had been dropping to raw curl or doing extra lookups.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodev-apollo-cli
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Agent-friendly CLI for the Apollo API
5
5
  Project-URL: Homepage, https://github.com/qodevai/apollo-cli
6
6
  Project-URL: Repository, https://github.com/qodevai/apollo-cli
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.11
21
21
  Requires-Dist: cyclopts>=3.0
22
- Requires-Dist: qodev-apollo-api>=0.3.0
22
+ Requires-Dist: qodev-apollo-api>=0.3.1
23
23
  Requires-Dist: rich>=13.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: mypy>=1.13.0; extra == 'dev'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qodev-apollo-cli"
3
- version = "1.2.0"
3
+ version = "1.2.1"
4
4
  description = "Agent-friendly CLI for the Apollo API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -20,7 +20,7 @@ classifiers = [
20
20
  dependencies = [
21
21
  "cyclopts>=3.0",
22
22
  "rich>=13.0",
23
- "qodev-apollo-api>=0.3.0",
23
+ "qodev-apollo-api>=0.3.1",
24
24
  ]
25
25
 
26
26
  [project.optional-dependencies]
@@ -25,6 +25,10 @@ async def search(
25
25
  """Search recorded conversations."""
26
26
  filters: dict = {}
27
27
  if query:
28
+ # `q_keywords` is Apollo's universal keyword-search param (contacts/accounts/deals/
29
+ # people all use it). The conversations/search endpoint is undocumented and we
30
+ # haven't confirmed it honours the filter server-side — if a search returns
31
+ # everything unfiltered, this is the param to revisit.
28
32
  filters["q_keywords"] = query
29
33
 
30
34
  async with ctx.client() as client:
@@ -2,9 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Annotated, Any
5
+ from typing import Annotated, Any, cast
6
6
 
7
7
  from cyclopts import App, Parameter
8
+ from qodev_apollo_api import RoleAssignment
8
9
 
9
10
  from apollo_cli.context import ctx
10
11
  from apollo_cli.formatters.deals import format_deal_detail, format_deal_list
@@ -86,19 +87,26 @@ async def role_types() -> None:
86
87
 
87
88
 
88
89
  def _existing_roles(deal: Any) -> list[dict]:
89
- """Flatten a deal's current opportunity_contact_roles into update_roles entries."""
90
+ """Flatten a deal's current opportunity_contact_roles into update_roles entries.
91
+
92
+ ``opportunity_contact_role_type_id`` is only included when the existing role
93
+ actually has one — we never send an explicit ``null`` (see ``_clean_roles``).
94
+ """
90
95
  roles: list[dict] = []
91
96
  for r in getattr(deal, "opportunity_contact_roles", []) or []:
92
- role_type_id = None
93
- if r.role:
94
- role_type_id = r.role[0].opportunity_contact_role_type_id
95
- roles.append(
96
- {
97
- "contact_id": r.contact_id,
98
- "opportunity_contact_role_type_id": role_type_id,
99
- "is_primary": bool(r.is_primary),
100
- }
101
- )
97
+ entry: dict = {"contact_id": r.contact_id, "is_primary": bool(r.is_primary)}
98
+ if r.role and r.role[0].opportunity_contact_role_type_id:
99
+ entry["opportunity_contact_role_type_id"] = r.role[0].opportunity_contact_role_type_id
100
+ roles.append(entry)
101
+ return roles
102
+
103
+
104
+ def _clean_roles(roles: list[dict]) -> list[dict]:
105
+ """Drop any ``opportunity_contact_role_type_id`` that is ``None`` so we never POST an
106
+ explicit null (Apollo may reject roles without a role type — omit the key instead)."""
107
+ for r in roles:
108
+ if r.get("opportunity_contact_role_type_id") is None:
109
+ r.pop("opportunity_contact_role_type_id", None)
102
110
  return roles
103
111
 
104
112
 
@@ -148,7 +156,7 @@ async def set_role(
148
156
 
149
157
  entry = next((r for r in roles if r["contact_id"] == contact_id), None)
150
158
  if entry is None:
151
- entry = {"contact_id": contact_id, "opportunity_contact_role_type_id": None, "is_primary": False}
159
+ entry = {"contact_id": contact_id, "is_primary": False}
152
160
  roles.append(entry)
153
161
  if role_type_id is not None:
154
162
  entry["opportunity_contact_role_type_id"] = role_type_id
@@ -156,6 +164,8 @@ async def set_role(
156
164
  for r in roles:
157
165
  r["is_primary"] = r["contact_id"] == contact_id
158
166
 
159
- updated = await client.update_opportunity_roles(id, roles)
167
+ # Entries are built dynamically (conditional keys, pop), so they're plain dicts;
168
+ # cast to the client's RoleAssignment TypedDict at the boundary.
169
+ updated = await client.update_opportunity_roles(id, cast("list[RoleAssignment]", _clean_roles(roles)))
160
170
 
161
171
  output(updated, ctx=ctx, format_fn=format_deal_detail)
@@ -51,9 +51,9 @@ async def search(
51
51
  async with ctx.client() as client:
52
52
  result = await client.search_people(**filters)
53
53
 
54
- # search_people returns the raw Apollo dict; people live under "people" (and
55
- # sometimes "contacts" for matched CRM records).
56
- items = result.get("people") or result.get("contacts") or []
54
+ # search_people returns the raw Apollo dict. Results live under "people"; matched
55
+ # CRM records come back under "contacts". Merge both so we never silently drop half.
56
+ items = [*(result.get("people") or []), *(result.get("contacts") or [])]
57
57
  pagination = result.get("pagination", {})
58
58
  total = pagination.get("total_entries", len(items))
59
59
 
@@ -6,6 +6,13 @@ from typing import Any
6
6
 
7
7
  from apollo_cli.formatters.generic import detail_table, list_table
8
8
 
9
+
10
+ def _get(item: Any, key: str) -> Any:
11
+ """Read ``key`` from a Pydantic model (attr) or a dict — so the rich detail view
12
+ renders whether ``get_conversation()`` returns models or raw dicts."""
13
+ return item.get(key) if isinstance(item, dict) else getattr(item, key, None)
14
+
15
+
9
16
  CONVERSATION_LIST_COLUMNS = [
10
17
  ("ID", "id"),
11
18
  ("Topic", "topic"),
@@ -42,38 +49,38 @@ def format_conversation_list(items: list[Any], *, total: int = 0, page: int = 1)
42
49
 
43
50
  def format_conversation_detail(data: Any) -> str:
44
51
  """Format a single conversation (with transcript & summary) as a markdown detail view."""
45
- topic = getattr(data, "topic", None) or "Conversation"
52
+ topic = _get(data, "topic") or "Conversation"
46
53
  md = detail_table(data, CONVERSATION_DETAIL_FIELDS, title=f"Conversation: {topic}")
47
54
 
48
55
  # Participants (richer than the participant_names list in the metadata table)
49
- participants = getattr(data, "participants_info", None) or []
56
+ participants = _get(data, "participants_info") or []
50
57
  if participants:
51
58
  md += "\n\n## Participants\n"
52
59
  for p in participants:
53
- name = getattr(p, "name", None) or "Unknown"
54
- title = getattr(p, "title", None)
55
- account = getattr(p, "account_name", None)
56
- internal = getattr(p, "is_internal_participant", None)
60
+ name = _get(p, "name") or "Unknown"
61
+ title = _get(p, "title")
62
+ account = _get(p, "account_name")
63
+ internal = _get(p, "is_internal_participant")
57
64
  suffix = ", ".join(x for x in [title, account] if x)
58
65
  tag = " (internal)" if internal else ""
59
66
  md += f"\n- **{name}**{tag}" + (f" — {suffix}" if suffix else "")
60
67
 
61
68
  # Associated deals
62
- deals = getattr(data, "deals", None) or []
69
+ deals = _get(data, "deals") or []
63
70
  if deals:
64
71
  md += "\n\n## Deals\n"
65
72
  for d in deals:
66
- name = getattr(d, "name", None) or getattr(d, "id", "Unknown")
67
- account = getattr(d, "account_name", None)
73
+ name = _get(d, "name") or _get(d, "id") or "Unknown"
74
+ account = _get(d, "account_name")
68
75
  md += f"\n- {name}" + (f" ({account})" if account else "")
69
76
 
70
77
  # AI-generated call summary (detail endpoint only)
71
- summary = getattr(data, "call_summary", None)
78
+ summary = _get(data, "call_summary")
72
79
  if summary:
73
80
  md += _format_summary(summary)
74
81
 
75
82
  # Transcript (detail endpoint only)
76
- if getattr(data, "transcript", None):
83
+ if _get(data, "transcript"):
77
84
  md += "\n\n" + format_transcript(data)
78
85
 
79
86
  return md
@@ -81,13 +88,13 @@ def format_conversation_detail(data: Any) -> str:
81
88
 
82
89
  def format_transcript(data: Any) -> str:
83
90
  """Render just the transcript of a conversation as `**Speaker:** sentence` lines."""
84
- segments = getattr(data, "transcript", None) or []
91
+ segments = _get(data, "transcript") or []
85
92
  if not segments:
86
93
  return "## Transcript\n\n_No transcript available._"
87
94
  lines = ["## Transcript", ""]
88
95
  for seg in segments:
89
- speaker = getattr(seg, "participant_name", None) or "Unknown"
90
- sentence = getattr(seg, "spoken_sentence", None) or ""
96
+ speaker = _get(seg, "participant_name") or "Unknown"
97
+ sentence = _get(seg, "spoken_sentence") or ""
91
98
  lines.append(f"- **{speaker}:** {sentence}")
92
99
  return "\n".join(lines)
93
100
 
@@ -95,10 +102,10 @@ def format_transcript(data: Any) -> str:
95
102
  def _format_summary(summary: Any) -> str:
96
103
  """Render the AI call summary (outcome, pain points, objections, next steps)."""
97
104
  md = "\n\n## Call Summary\n"
98
- outcome = getattr(summary, "outcome", None)
105
+ outcome = _get(summary, "outcome")
99
106
  if outcome:
100
107
  md += f"\n**Outcome:** {outcome}\n"
101
- pricing = getattr(summary, "pricing_discussion", None)
108
+ pricing = _get(summary, "pricing_discussion")
102
109
  if pricing:
103
110
  md += f"\n**Pricing discussion:** {pricing}\n"
104
111
 
@@ -107,11 +114,11 @@ def _format_summary(summary: Any) -> str:
107
114
  ("Objections", "objections", "text"),
108
115
  ("Next Steps", "next_steps", "step"),
109
116
  ):
110
- items = getattr(summary, attr, None) or []
117
+ items = _get(summary, attr) or []
111
118
  if items:
112
119
  md += f"\n### {label}\n"
113
120
  for item in items:
114
- text = getattr(item, field, None) or ""
115
- who = getattr(item, "participant_name", None)
121
+ text = _get(item, field) or ""
122
+ who = _get(item, "participant_name")
116
123
  md += f"\n- {text}" + (f" — _{who}_" if who else "")
117
124
  return md
@@ -20,11 +20,20 @@ def resolve_stage_id(name: str, stages: list[Any], *, kind: str = "stage") -> st
20
20
  target = name.strip().lower()
21
21
  match = next((s for s in stages if (_field(s, "name") or "").lower() == target), None)
22
22
  if match is None:
23
- available = ", ".join(sorted(n for s in stages if (n := _field(s, "name"))))
24
- raise ValueError(f"No {kind} named {name!r}. Available: {available or '(none)'}")
23
+ names = sorted(n for s in stages if (n := _field(s, "name")))
24
+ raise ValueError(f"No {kind} named {name!r}. Available: {_preview(names)}")
25
25
  return _field(match, "id")
26
26
 
27
27
 
28
+ def _preview(names: list[str], limit: int = 15) -> str:
29
+ """Render a name list for an error message, capped so it can't get huge."""
30
+ if not names:
31
+ return "(none)"
32
+ if len(names) <= limit:
33
+ return ", ".join(names)
34
+ return f"{', '.join(names[:limit])}, … (+{len(names) - limit} more)"
35
+
36
+
28
37
  def parse_comma_list(raw: str) -> list[str]:
29
38
  """Parse a comma-separated CLI argument into a list of stripped, non-empty tokens.
30
39
 
@@ -375,6 +375,28 @@ class TestPeopleCommands:
375
375
  assert data["items"][0]["name"] == "Jane"
376
376
  assert data["total"] == 1
377
377
 
378
+ @pytest.mark.asyncio
379
+ async def test_people_search_merges_people_and_contacts(self, capsys) -> None:
380
+ """When Apollo returns both `people` and `contacts`, neither half is dropped."""
381
+ mock_client = MagicMock()
382
+ mock_client.search_people = AsyncMock(
383
+ return_value={
384
+ "people": [{"id": "p1", "name": "Jane"}],
385
+ "contacts": [{"id": "c1", "name": "John"}],
386
+ "pagination": {"total_entries": 2},
387
+ }
388
+ )
389
+
390
+ _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
391
+
392
+ with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
393
+ from apollo_cli.commands.people import search
394
+
395
+ await search(keywords="jane")
396
+
397
+ data = json.loads(capsys.readouterr().out)
398
+ assert {p["name"] for p in data["items"]} == {"Jane", "John"}
399
+
378
400
 
379
401
  class TestStageNameFilter:
380
402
  @pytest.mark.asyncio
@@ -454,6 +476,30 @@ class TestDealRoleCommands:
454
476
  assert by_contact["c-new"]["is_primary"] is True
455
477
  assert by_contact["c-old"]["is_primary"] is False # demoted
456
478
  assert by_contact["c-old"]["opportunity_contact_role_type_id"] == "rt-x" # preserved
479
+ # New contact without --role-type: the key is omitted, never sent as an explicit null.
480
+ assert "opportunity_contact_role_type_id" not in by_contact["c-new"]
481
+
482
+ @pytest.mark.asyncio
483
+ async def test_set_role_never_sends_null_role_type(self, capsys) -> None:
484
+ """An existing role stored without a role type is sent without the key (no null)."""
485
+ from qodev_apollo_api.models import Deal
486
+
487
+ deal = Deal.model_validate(
488
+ {"id": "d1", "opportunity_contact_roles": [{"id": "r1", "contact_id": "c-old", "is_primary": True}]}
489
+ )
490
+ mock_client = MagicMock()
491
+ mock_client.get_deal = AsyncMock(return_value=deal)
492
+ mock_client.update_opportunity_roles = AsyncMock(return_value=deal)
493
+
494
+ _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
495
+
496
+ with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
497
+ from apollo_cli.commands.deals import set_role
498
+
499
+ await set_role("d1", contact_id="c-new")
500
+
501
+ _, roles = mock_client.update_opportunity_roles.call_args.args
502
+ assert all("opportunity_contact_role_type_id" not in r for r in roles)
457
503
 
458
504
 
459
505
  class TestCustomFieldsCommand:
@@ -68,3 +68,21 @@ def test_detail_formatter_minimal_conversation() -> None:
68
68
  assert "Conversation: Quick sync" in md
69
69
  assert "## Transcript" not in md
70
70
  assert "## Call Summary" not in md
71
+
72
+
73
+ def test_detail_formatter_handles_raw_dict() -> None:
74
+ """The rich detail view renders from a raw dict too, not only Pydantic models."""
75
+ data = {
76
+ "id": "c1",
77
+ "topic": "Dict sync",
78
+ "participants_info": [{"name": "Jane", "title": "VP", "is_internal_participant": True}],
79
+ "deals": [{"id": "d1", "name": "Enterprise Deal", "account_name": "Acme"}],
80
+ "call_summary": {"outcome": "Good", "next_steps": [{"step": "Follow up"}]},
81
+ "transcript": [{"participant_name": "Jane", "spoken_sentence": "Hello."}],
82
+ }
83
+ md = format_conversation_detail(data)
84
+ assert "Conversation: Dict sync" in md
85
+ assert "## Participants" in md and "Jane" in md and "(internal)" in md
86
+ assert "## Deals" in md and "Enterprise Deal" in md
87
+ assert "## Call Summary" in md and "Good" in md and "Follow up" in md
88
+ assert "## Transcript" in md and "**Jane:** Hello." in md
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import pytest
6
6
 
7
- from apollo_cli.util import parse_comma_list
7
+ from apollo_cli.util import parse_comma_list, resolve_stage_id
8
8
 
9
9
 
10
10
  class TestParseCommaList:
@@ -31,3 +31,26 @@ class TestParseCommaList:
31
31
  input (tested above) is fine because it maps cleanly to "flag not provided"."""
32
32
  with pytest.raises(ValueError, match="only separators"):
33
33
  parse_comma_list(raw)
34
+
35
+
36
+ class TestResolveStageId:
37
+ def test_matches_case_insensitively(self) -> None:
38
+ stages = [{"id": "s1", "name": "Negotiation"}, {"id": "s2", "name": "Won"}]
39
+ assert resolve_stage_id("negotiation", stages) == "s1"
40
+
41
+ def test_works_with_model_like_objects(self) -> None:
42
+ class S:
43
+ def __init__(self, id, name):
44
+ self.id, self.name = id, name
45
+
46
+ assert resolve_stage_id("Won", [S("s1", "Neg"), S("s2", "Won")]) == "s2"
47
+
48
+ def test_unknown_name_lists_available(self) -> None:
49
+ stages = [{"id": "s1", "name": "Won"}, {"id": "s2", "name": "Lost"}]
50
+ with pytest.raises(ValueError, match=r"No stage named 'Nope'\. Available: Lost, Won"):
51
+ resolve_stage_id("Nope", stages)
52
+
53
+ def test_available_list_is_capped(self) -> None:
54
+ stages = [{"id": str(i), "name": f"Stage{i:02d}"} for i in range(30)]
55
+ with pytest.raises(ValueError, match=r"\(\+15 more\)"):
56
+ resolve_stage_id("missing", stages)
@@ -472,7 +472,7 @@ wheels = [
472
472
 
473
473
  [[package]]
474
474
  name = "qodev-apollo-api"
475
- version = "0.3.0"
475
+ version = "0.3.2"
476
476
  source = { directory = "../apollo-api" }
477
477
  dependencies = [
478
478
  { name = "httpx" },
@@ -494,7 +494,7 @@ provides-extras = ["dev"]
494
494
 
495
495
  [[package]]
496
496
  name = "qodev-apollo-cli"
497
- version = "1.2.0"
497
+ version = "1.2.1"
498
498
  source = { editable = "." }
499
499
  dependencies = [
500
500
  { name = "cyclopts" },