From e872902da40b43e52121cda44dbad76ba610743c Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Wed, 1 Jul 2026 23:23:24 +0200 Subject: [PATCH 1/3] feat!: rename find-by-linkedin to upsert-by-linkedin (breaking) find-by-linkedin conflated a read (find) with a write (--create) under a read verb, returned a bare id, treated the normal "not in CRM" outcome as an exit-1 error, and overlapped confusingly with `search --linkedin-url`. Replace it with `contacts upsert-by-linkedin` (honest get-or-create): - returns the full contact + a `created` flag (not a bare contact_id) - a missing contact is a normal result, not an error; --name is required to create - name-searches before creating to avoid duplicating a contact stored under a different URL - read-only lookups now use `contacts search --linkedin-url` BREAKING: find-by-linkedin and its --create flag are removed. Bumps to 1.0.0. Updates SKILL.md / references / README and the CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 21 +++-- README.md | 16 ++-- pyproject.toml | 2 +- src/apollo_cli/commands/contacts.py | 85 +++++++++++++------ src/apollo_cli/linkedin.py | 2 +- src/apollo_cli/skills/SKILL.md | 16 ++-- .../skills/references/contact-workflows.md | 23 ++--- tests/test_commands.py | 85 +++++++++++++++++-- 8 files changed, 182 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8bd3a..312816c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [Unreleased] +## [1.0.0] - 2026-07-01 + +### Changed + +- **BREAKING — `contacts find-by-linkedin` is now `contacts upsert-by-linkedin`** with + honest get-or-create semantics. It returns the full contact plus a `created` flag + (not a bare `contact_id`), a missing contact is a normal result rather than an exit-1 + `not_found` error, and `--name` is required to create. Before creating it name-searches + to avoid duplicating a contact stored under a different URL. Read-only lookups now use + `contacts search --linkedin-url`. + +### Removed + +- **BREAKING — `contacts find-by-linkedin`** (and its `--create` flag). The read path is + `contacts search --linkedin-url`; the write path is `contacts upsert-by-linkedin`. ### Added @@ -23,11 +37,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). trailing slash, lowercase `%hex`); the filter was passed through verbatim, so a normal `https://.../in/slug/` URL silently returned zero results. Inputs are now canonicalized to Apollo's stored form before searching (new `apollo_cli.linkedin` module). -- **`contacts find-by-linkedin` no longer under-matches.** It resolves the URL via an - exact canonical search first, instead of the API client's `find_contact_by_linkedin_url`, - whose `https://` normalization never matches Apollo's `http://`-stored URLs (its URL tier - always missed and fell through to name search). Name-search / auto-create fallbacks are - still used when the canonical search finds nothing. ## [0.1.0] - 2026-02-26 diff --git a/README.md b/README.md index 1b00abc..a751d72 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ $ qodev-apollo-cli usage | | `get` | Get contact details by ID | | | `create` | Create a new contact (`--first-name`, `--last-name`, `--email`, etc.) | | | `update` | Update contact (`--title`, `--label-ids`) | -| | `find-by-linkedin` | Find contact by LinkedIn URL (`--create`, `--stage-id`) | +| | `upsert-by-linkedin` | Get or create a contact by LinkedIn URL (`--name`, `--title`, `--stage-id`) | | | `stages` | List all contact stages | | **accounts** | `search` | Search companies/accounts (`--query`, `--domain`) | | | `get` | Get account details by ID | @@ -167,15 +167,15 @@ qodev-apollo-cli deals search --stage-id ### LinkedIn integration ```bash -# Find contact by LinkedIn URL -qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" +# Read-only lookup by LinkedIn URL (returns 0..n contacts, never writes) +qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith" -# Auto-create if not found -qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create +# Get or create a contact by LinkedIn URL (upsert); --name is required to create +qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith" -# Assign to stage on creation -qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" \ - --create --stage-id +# Set title / stage when creating +qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \ + --name "Jane Smith" --title "VP Engineering" --stage-id ``` ### Company enrichment (FREE) diff --git a/pyproject.toml b/pyproject.toml index ed8e1f8..24a2dad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "qodev-apollo-cli" -version = "0.1.0" +version = "1.0.0" description = "Agent-friendly CLI for the Apollo API" readme = "README.md" requires-python = ">=3.11" diff --git a/src/apollo_cli/commands/contacts.py b/src/apollo_cli/commands/contacts.py index cc5fbef..a5ace64 100644 --- a/src/apollo_cli/commands/contacts.py +++ b/src/apollo_cli/commands/contacts.py @@ -13,7 +13,7 @@ format_stages_list, ) from apollo_cli.linkedin import apollo_canonical_linkedin_url -from apollo_cli.output import output, output_list +from apollo_cli.output import error, generic_markdown, output, output_list from apollo_cli.util import parse_comma_list contacts_app = App(name="contacts", help="Manage contacts.") @@ -109,37 +109,72 @@ async def update( output(result, ctx=ctx, format_fn=format_contact_detail) -@contacts_app.command(name="find-by-linkedin") -async def find_by_linkedin( +def _format_upsert_result(data: dict) -> str: + status = "Created new contact" if data["created"] else "Found existing contact" + return f"**{status}**\n\n{generic_markdown(data['contact'])}" + + +@contacts_app.command(name="upsert-by-linkedin") +async def upsert_by_linkedin( url: Annotated[str, Parameter(help="LinkedIn profile URL")], *, - name: Annotated[str | None, Parameter(name="--name", help="Person's full name (for fallback search)")] = None, - create_flag: Annotated[bool, Parameter(name="--create", help="Auto-create if not found", negative="")] = False, - stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Stage ID for auto-created contact")] = None, + name: Annotated[ + str | None, + Parameter(name="--name", help="Full name 'First Last' — required to create the contact if it doesn't exist"), + ] = None, + title: Annotated[str | None, Parameter(name="--title", help="Job title (used only when creating)")] = None, + company: Annotated[str | None, Parameter(name="--company", help="Company name (used only when creating)")] = None, + stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Stage ID (used only when creating)")] = None, ) -> None: - """Find a contact by LinkedIn URL with fallback strategies.""" + """Get or create a contact by LinkedIn URL (upsert). + + Resolves the URL to an existing contact and returns it, or — if none exists — + creates one (requires ``--name``) and returns it. The result carries a ``created`` + flag. For a read-only lookup that never writes, use ``contacts search --linkedin-url``. + """ canonical = apollo_canonical_linkedin_url(url) async with ctx.client() as client: - # Reliable exact-match on Apollo's canonical URL first. The client's - # find_contact_by_linkedin_url normalizes to https://, which never matches - # Apollo's http://-stored URLs, so its URL tier always misses; do the search - # here and only delegate for the name-search / auto-create fallbacks. - result = await client.search_contacts(linkedin_url=canonical, limit=5) - contact_id = result.items[0].id if result.items else None - if not contact_id and (name or create_flag): - contact_id = await client.find_contact_by_linkedin_url( - linkedin_url=url, - person_name=name, - create_if_missing=create_flag, - contact_stage_id=stage_id, + # 1. Exact-match lookup on Apollo's canonical URL. + result = await client.search_contacts(linkedin_url=canonical, limit=1) + existing = result.items[0] if result.items else None + + # 2. Name fallback — catch a contact stored under a drifted/numeric URL so we + # don't create a duplicate; accept only an exact canonical-URL identity match. + if existing is None and name: + by_name = await client.search_contacts(q_keywords=name, limit=10) + existing = next( + ( + c + for c in by_name.items + if c.linkedin_url and apollo_canonical_linkedin_url(c.linkedin_url) == canonical + ), + None, ) - if contact_id: - output({"contact_id": contact_id}, ctx=ctx) - else: - from apollo_cli.output import error - - error("Contact not found.", ctx=ctx, code="not_found", exit_code=1) + if existing is not None: + output({"created": False, "contact": existing}, ctx=ctx, format_fn=_format_upsert_result) + return + + # 3. Create — needs a name to split into first/last. + if not name: + error( + 'No contact for that LinkedIn URL. Pass --name "First Last" to create one.', + ctx=ctx, + code="name_required", + exit_code=2, + ) + return + first, _, last = name.strip().partition(" ") + fields: dict = {"linkedin_url": canonical} + if title: + fields["title"] = title + if company: + fields["company_name"] = company + if stage_id: + fields["contact_stage_id"] = stage_id + created = await client.create_contact(first, last, **fields) + + output({"created": True, "contact": created}, ctx=ctx, format_fn=_format_upsert_result) @contacts_app.command diff --git a/src/apollo_cli/linkedin.py b/src/apollo_cli/linkedin.py index c872902..fd07d66 100644 --- a/src/apollo_cli/linkedin.py +++ b/src/apollo_cli/linkedin.py @@ -5,7 +5,7 @@ lowercase percent-encoding. Apollo's ``linkedin_url`` search filter is a literal string match, so any other shape a user pastes (``https://``, missing ``www``, a trailing slash, uppercase ``%HEX``, tracking query params) silently returns zero results. Canonicalizing -before searching is what makes ``contacts search --linkedin-url`` and ``find-by-linkedin`` +before searching is what makes ``contacts search --linkedin-url`` and ``upsert-by-linkedin`` match reliably. """ diff --git a/src/apollo_cli/skills/SKILL.md b/src/apollo_cli/skills/SKILL.md index de3a22c..672a783 100644 --- a/src/apollo_cli/skills/SKILL.md +++ b/src/apollo_cli/skills/SKILL.md @@ -33,7 +33,7 @@ Get your API key from [Apollo.io Settings → API](https://app.apollo.io/#/setti | `contacts get ID` | Get contact details | | `contacts create --first-name F --last-name L [--email E] [--title T] [--company C] [--linkedin-url URL]` | Create contact | | `contacts update ID [--title T] [--label-ids IDS]` | Update contact | -| `contacts find-by-linkedin URL [--create] [--name N] [--stage-id ID]` | Find contact by LinkedIn URL | +| `contacts upsert-by-linkedin URL [--name N] [--title T] [--stage-id ID]` | Get or create a contact by LinkedIn URL | | `contacts stages` | List all contact stages | ### accounts @@ -182,15 +182,15 @@ qodev-apollo-cli deals search --stage-id ### LinkedIn integration ```bash -# Find contact by LinkedIn URL -qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" +# Read-only lookup by LinkedIn URL (returns 0..n contacts, never writes) +qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith" -# Auto-create if not found -qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create +# Get or create a contact by LinkedIn URL (upsert); --name is required to create +qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith" -# Assign to stage on creation -qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" \ - --create --stage-id +# Set title / stage when creating +qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \ + --name "Jane Smith" --title "VP Engineering" --stage-id ``` ## References diff --git a/src/apollo_cli/skills/references/contact-workflows.md b/src/apollo_cli/skills/references/contact-workflows.md index fc7eaf5..140bab9 100644 --- a/src/apollo_cli/skills/references/contact-workflows.md +++ b/src/apollo_cli/skills/references/contact-workflows.md @@ -20,22 +20,25 @@ qodev-apollo-cli contacts search --query "engineer" --page 2 --limit 50 ## LinkedIn Integration -Find or create contacts from LinkedIn profiles: +Two commands cover LinkedIn URLs — a read-only lookup and an upsert: ```bash -# Find existing contact by LinkedIn URL -qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" +# Read-only lookup — returns 0..n matching contacts, never writes +qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith" -# Auto-create if not found -qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create +# Upsert — resolve to an existing contact, or create one (--name required to create). +# The result carries a "created" flag. +qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith" -# Specify name for fallback search -qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith" - -# Assign to stage on creation -qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create --stage-id +# Set title / company / stage when creating +qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \ + --name "Jane Smith" --title "VP Engineering" --stage-id ``` +URLs are canonicalized to Apollo's exact-match form automatically, so any common shape +(`https://`, trailing slash, `www`/no-`www`) resolves the same contact. Before creating, +the upsert also name-searches to avoid duplicating a contact stored under a different URL. + ## Contact Creation Create new contacts manually: diff --git a/tests/test_commands.py b/tests/test_commands.py index f79a370..e3fc308 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -86,24 +86,91 @@ async def test_contacts_search_canonicalizes_linkedin_url(self, sample_contact: assert call_kwargs["linkedin_url"] == "http://www.linkedin.com/in/janesmith" @pytest.mark.asyncio - async def test_find_by_linkedin_uses_canonical_search(self, capsys) -> None: - """find-by-linkedin resolves via an exact canonical search (not the client's https path).""" + async def test_upsert_returns_existing_contact(self, capsys) -> None: + """upsert-by-linkedin returns the existing contact (created=False) via canonical search, no write.""" mock_client = MagicMock() - match = MagicMock(id="abc-123", linkedin_url="http://www.linkedin.com/in/janesmith") - mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[match], total=1, page=1)) - mock_client.find_contact_by_linkedin_url = AsyncMock(return_value=None) + contact = {"id": "c1", "name": "Jane Smith", "linkedin_url": "http://www.linkedin.com/in/janesmith"} + mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[contact], total=1, page=1)) + mock_client.create_contact = 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.contacts import find_by_linkedin + from apollo_cli.commands.contacts import upsert_by_linkedin - await find_by_linkedin("https://www.linkedin.com/in/janesmith/") + await upsert_by_linkedin("https://www.linkedin.com/in/janesmith/") assert mock_client.search_contacts.call_args.kwargs["linkedin_url"] == "http://www.linkedin.com/in/janesmith" - mock_client.find_contact_by_linkedin_url.assert_not_called() # found via search; no fallback + mock_client.create_contact.assert_not_called() data = json.loads(capsys.readouterr().out) - assert data["contact_id"] == "abc-123" + assert data["created"] is False + assert data["contact"]["id"] == "c1" + + @pytest.mark.asyncio + async def test_upsert_creates_when_missing(self, capsys) -> None: + """upsert-by-linkedin creates (created=True) when none exists and --name is given.""" + mock_client = MagicMock() + mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[], total=0, page=1)) + mock_client.create_contact = AsyncMock( + return_value={"id": "new1", "name": "Jane Smith", "linkedin_url": "http://www.linkedin.com/in/janesmith"} + ) + + _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.contacts import upsert_by_linkedin + + await upsert_by_linkedin("https://www.linkedin.com/in/janesmith/", name="Jane Smith", title="VP") + + args, kwargs = mock_client.create_contact.call_args + assert args == ("Jane", "Smith") + assert kwargs["linkedin_url"] == "http://www.linkedin.com/in/janesmith" + assert kwargs["title"] == "VP" + data = json.loads(capsys.readouterr().out) + assert data["created"] is True + assert data["contact"]["id"] == "new1" + + @pytest.mark.asyncio + async def test_upsert_requires_name_to_create(self, capsys) -> None: + """upsert-by-linkedin errors (exit 2, code name_required) when missing and no --name given.""" + mock_client = MagicMock() + mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[], total=0, page=1)) + mock_client.create_contact = 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.contacts import upsert_by_linkedin + + with pytest.raises(SystemExit) as exc: + await upsert_by_linkedin("https://www.linkedin.com/in/janesmith/") + + assert exc.value.code == 2 + mock_client.create_contact.assert_not_called() + assert json.loads(capsys.readouterr().out)["code"] == "name_required" + + @pytest.mark.asyncio + async def test_upsert_name_fallback_prevents_duplicate(self, capsys) -> None: + """A URL miss but a name-search hit with the same canonical URL returns existing, not a new create.""" + mock_client = MagicMock() + existing = MagicMock(id="c9", linkedin_url="https://www.linkedin.com/in/janesmith/") + mock_client.search_contacts = AsyncMock( + side_effect=[ + MockSearchResult(items=[], total=0, page=1), # URL lookup misses + MockSearchResult(items=[existing], total=1, page=1), # name search hits + ] + ) + mock_client.create_contact = 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.contacts import upsert_by_linkedin + + await upsert_by_linkedin("https://www.linkedin.com/in/janesmith/", name="Jane Smith") + + mock_client.create_contact.assert_not_called() # deduped — no duplicate created + assert json.loads(capsys.readouterr().out)["created"] is False @pytest.mark.asyncio async def test_contacts_get(self, sample_contact: dict, capsys) -> None: From 221e5b0797c391e04813531cf9cbef4d2db505ad Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Wed, 1 Jul 2026 23:38:57 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(upsert):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20name=20validation,=20real-model=20tests,=20slug=20lowercasin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - require a full "First Last" to create; a single-word --name previously created a contact with an empty last name (silent, untested). + test. - canonicalizer lowercases the whole slug (was %hex-only) so a mixed-case slug matches Apollo's lowercase storage; also simpler (drops the _PCT_RE pass). + test. - upsert renders the contact via format_contact_detail — consistent with get/create/update (generic_markdown silently dropped nested contact fields). - upsert command tests now use real Contact pydantic models, exercising the actual serialization path (previously plain dicts / MagicMock gave false confidence). - polish: _format_upsert_result typed dict[str, Any]; comment noting first-match is intentional. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apollo_cli/commands/contacts.py | 16 ++++++++------- src/apollo_cli/linkedin.py | 10 +++++----- tests/test_commands.py | 30 ++++++++++++++++++++++++++--- tests/test_linkedin.py | 1 + 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/apollo_cli/commands/contacts.py b/src/apollo_cli/commands/contacts.py index a5ace64..c5f215b 100644 --- a/src/apollo_cli/commands/contacts.py +++ b/src/apollo_cli/commands/contacts.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any from cyclopts import App, Parameter @@ -13,7 +13,7 @@ format_stages_list, ) from apollo_cli.linkedin import apollo_canonical_linkedin_url -from apollo_cli.output import error, generic_markdown, output, output_list +from apollo_cli.output import error, output, output_list from apollo_cli.util import parse_comma_list contacts_app = App(name="contacts", help="Manage contacts.") @@ -109,9 +109,9 @@ async def update( output(result, ctx=ctx, format_fn=format_contact_detail) -def _format_upsert_result(data: dict) -> str: +def _format_upsert_result(data: dict[str, Any]) -> str: status = "Created new contact" if data["created"] else "Found existing contact" - return f"**{status}**\n\n{generic_markdown(data['contact'])}" + return f"**{status}**\n\n{format_contact_detail(data['contact'])}" @contacts_app.command(name="upsert-by-linkedin") @@ -140,6 +140,8 @@ async def upsert_by_linkedin( # 2. Name fallback — catch a contact stored under a drifted/numeric URL so we # don't create a duplicate; accept only an exact canonical-URL identity match. + # First match wins: upsert just needs to know one exists (unlike the old client, + # we intentionally don't treat >1 match as ambiguous). if existing is None and name: by_name = await client.search_contacts(q_keywords=name, limit=10) existing = next( @@ -155,8 +157,9 @@ async def upsert_by_linkedin( output({"created": False, "contact": existing}, ctx=ctx, format_fn=_format_upsert_result) return - # 3. Create — needs a name to split into first/last. - if not name: + # 3. Create — Apollo needs both a first and a last name. + first, _, last = name.strip().partition(" ") if name else ("", "", "") + if not (first and last): error( 'No contact for that LinkedIn URL. Pass --name "First Last" to create one.', ctx=ctx, @@ -164,7 +167,6 @@ async def upsert_by_linkedin( exit_code=2, ) return - first, _, last = name.strip().partition(" ") fields: dict = {"linkedin_url": canonical} if title: fields["title"] = title diff --git a/src/apollo_cli/linkedin.py b/src/apollo_cli/linkedin.py index fd07d66..d497d63 100644 --- a/src/apollo_cli/linkedin.py +++ b/src/apollo_cli/linkedin.py @@ -2,9 +2,10 @@ Apollo stores and *exact-matches* LinkedIn profile URLs in the canonical form ``http://www.linkedin.com/in/`` — http scheme, ``www`` host, no trailing slash, -lowercase percent-encoding. Apollo's ``linkedin_url`` search filter is a literal string -match, so any other shape a user pastes (``https://``, missing ``www``, a trailing slash, -uppercase ``%HEX``, tracking query params) silently returns zero results. Canonicalizing +lowercased slug (Apollo lowercases the whole URL on its side). Apollo's ``linkedin_url`` +search filter is a literal string match, so any other shape a user pastes (``https://``, +missing ``www``, a trailing slash, a mixed-case slug or ``%HEX``, tracking query params) +silently returns zero results. Canonicalizing before searching is what makes ``contacts search --linkedin-url`` and ``upsert-by-linkedin`` match reliably. """ @@ -16,7 +17,6 @@ # Modern LinkedIn profile URLs are /in/. Legacy /pub/ URLs carry a multi-segment # id we must not truncate, so we leave anything that isn't /in/ untouched. _PROFILE_RE = re.compile(r"linkedin\.com/in/([^/?#]+)", re.IGNORECASE) -_PCT_RE = re.compile(r"%[0-9A-Fa-f]{2}") def apollo_canonical_linkedin_url(url: str) -> str: @@ -30,5 +30,5 @@ def apollo_canonical_linkedin_url(url: str) -> str: match = _PROFILE_RE.search(url.strip()) if not match: return url - slug = _PCT_RE.sub(lambda m: m.group(0).lower(), match.group(1).rstrip("/")) + slug = match.group(1).rstrip("/").lower() return f"http://www.linkedin.com/in/{slug}" diff --git a/tests/test_commands.py b/tests/test_commands.py index e3fc308..bd737b0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from qodev_apollo_api.models import Contact import apollo_cli.context as _ctx @@ -89,7 +90,9 @@ async def test_contacts_search_canonicalizes_linkedin_url(self, sample_contact: async def test_upsert_returns_existing_contact(self, capsys) -> None: """upsert-by-linkedin returns the existing contact (created=False) via canonical search, no write.""" mock_client = MagicMock() - contact = {"id": "c1", "name": "Jane Smith", "linkedin_url": "http://www.linkedin.com/in/janesmith"} + contact = Contact.model_validate( + {"id": "c1", "name": "Jane Smith", "linkedin_url": "http://www.linkedin.com/in/janesmith"} + ) mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[contact], total=1, page=1)) mock_client.create_contact = AsyncMock() @@ -112,7 +115,9 @@ async def test_upsert_creates_when_missing(self, capsys) -> None: mock_client = MagicMock() mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[], total=0, page=1)) mock_client.create_contact = AsyncMock( - return_value={"id": "new1", "name": "Jane Smith", "linkedin_url": "http://www.linkedin.com/in/janesmith"} + return_value=Contact.model_validate( + {"id": "new1", "name": "Jane Smith", "linkedin_url": "http://www.linkedin.com/in/janesmith"} + ) ) _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) @@ -149,11 +154,30 @@ async def test_upsert_requires_name_to_create(self, capsys) -> None: mock_client.create_contact.assert_not_called() assert json.loads(capsys.readouterr().out)["code"] == "name_required" + @pytest.mark.asyncio + async def test_upsert_rejects_single_word_name(self, capsys) -> None: + """A single-word --name can't create (Apollo needs first + last) — errors, no write.""" + mock_client = MagicMock() + mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[], total=0, page=1)) + mock_client.create_contact = 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.contacts import upsert_by_linkedin + + with pytest.raises(SystemExit) as exc: + await upsert_by_linkedin("https://www.linkedin.com/in/janesmith/", name="Cher") + + assert exc.value.code == 2 + mock_client.create_contact.assert_not_called() + assert json.loads(capsys.readouterr().out)["code"] == "name_required" + @pytest.mark.asyncio async def test_upsert_name_fallback_prevents_duplicate(self, capsys) -> None: """A URL miss but a name-search hit with the same canonical URL returns existing, not a new create.""" mock_client = MagicMock() - existing = MagicMock(id="c9", linkedin_url="https://www.linkedin.com/in/janesmith/") + existing = Contact.model_validate({"id": "c9", "linkedin_url": "https://www.linkedin.com/in/janesmith/"}) mock_client.search_contacts = AsyncMock( side_effect=[ MockSearchResult(items=[], total=0, page=1), # URL lookup misses diff --git a/tests/test_linkedin.py b/tests/test_linkedin.py index 4a359fc..dd0161e 100644 --- a/tests/test_linkedin.py +++ b/tests/test_linkedin.py @@ -18,6 +18,7 @@ "www.linkedin.com/in/daniel-wessel-575597b8", # no scheme "https://www.linkedin.com/in/daniel-wessel-575597b8?utm_source=share", # query params " https://www.linkedin.com/in/daniel-wessel-575597b8/ ", # surrounding whitespace + "https://www.linkedin.com/in/Daniel-Wessel-575597b8", # mixed-case slug (Apollo stores lowercase) ], ) def test_canonicalizes_common_variants_to_apollo_form(raw: str) -> None: From 95762b1341f6f0bbe47e441afd8ab112bef728b2 Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Thu, 2 Jul 2026 00:00:59 +0200 Subject: [PATCH 3/3] chore: reconcile CHANGELOG into a single 1.0.0 section after rebase onto main Rebasing onto main (which gained #2 notes + #3 parse_comma_list) left duplicate CHANGELOG headings; consolidate into one Added/Changed/Removed/Fixed set for 1.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 312816c..88256dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,37 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [1.0.0] - 2026-07-01 -### Changed - -- **BREAKING — `contacts find-by-linkedin` is now `contacts upsert-by-linkedin`** with - honest get-or-create semantics. It returns the full contact plus a `created` flag - (not a bare `contact_id`), a missing contact is a normal result rather than an exit-1 - `not_found` error, and `--name` is required to create. Before creating it name-searches - to avoid duplicating a contact stored under a different URL. Read-only lookups now use - `contacts search --linkedin-url`. - -### Removed - -- **BREAKING — `contacts find-by-linkedin`** (and its `--create` flag). The read path is - `contacts search --linkedin-url`; the write path is `contacts upsert-by-linkedin`. - ### Added - **notes**: `--opportunity-ids` on `create` and `--opportunity-id` filter on `search`. The underlying `qodev-apollo-api` client already supported opportunity attachment; only the CLI surface was missing. Enables attaching notes directly to deals/opportunities so they appear in the deal view (previously notes could only be attached to accounts/contacts, which don't surface on the opportunity UI). ### Changed +- **BREAKING — `contacts find-by-linkedin` is now `contacts upsert-by-linkedin`** with honest get-or-create semantics. It returns the full contact plus a `created` flag (not a bare `contact_id`), a missing contact is a normal result rather than an exit-1 `not_found` error, and `--name` is required to create. Before creating it name-searches to avoid duplicating a contact stored under a different URL. Read-only lookups now use `contacts search --linkedin-url`. - **internal**: Extracted the inline comma-splitting logic (used in `contacts update --label-ids`, `people search --titles/--locations`, `tasks create --contact-ids`, and `notes create --contact-ids/--account-ids/--opportunity-ids`) into a shared `apollo_cli.util.parse_comma_list` helper. Behavior is now consistent across every comma-list flag. +### Removed + +- **BREAKING — `contacts find-by-linkedin`** (and its `--create` flag). The read path is `contacts search --linkedin-url`; the write path is `contacts upsert-by-linkedin`. + ### Fixed - **comma-list flags — forgiving on typos, loud on garbage.** All comma-separated CLI arguments now drop embedded empty segments, whitespace-only tokens, and leading/trailing commas — so `--contact-ids "a,,b"` sends `["a", "b"]` instead of `["a", "", "b"]` (which Apollo rejects with a 400). Empty (`""`) or whitespace-only input maps to "flag not provided", but input like `",,,"` — where the user typed *something* that collapses to nothing — now surfaces as a validation error (exit code 83, `"validation"`) via the CLI's central error handler, instead of a raw Python traceback or a silent flag-omit. Affects every command that takes a comma-list flag. - **notes docs**: `README.md` and `skills/SKILL.md` referenced a non-existent `--note` flag on `notes create`; the actual flag has always been `--content`. Also surfaced the already-implemented `--account-id`/`--account-ids` flags in both docs (previously only `--contact-id`/`--contact-ids` were documented). AI agents following `SKILL.md` would have hit `--note` errors. -- **`contacts search --linkedin-url` now matches reliably.** Apollo stores and - exact-matches LinkedIn URLs as `http://www.linkedin.com/in/` (http, `www`, no - trailing slash, lowercase `%hex`); the filter was passed through verbatim, so a normal - `https://.../in/slug/` URL silently returned zero results. Inputs are now canonicalized - to Apollo's stored form before searching (new `apollo_cli.linkedin` module). +- **`contacts search --linkedin-url` now matches reliably.** Apollo stores and exact-matches LinkedIn URLs as `http://www.linkedin.com/in/` (http, `www`, no trailing slash, lowercase); the filter was passed through verbatim, so a normal `https://.../in/slug/` URL silently returned zero results. Inputs are now canonicalized to Apollo's stored form before searching (new `apollo_cli.linkedin` module). ## [0.1.0] - 2026-02-26