Skip to content

Add Zetkin and Mailchimp bulk tag helpers for branch re-tagging - #118

Open
conatus wants to merge 15 commits into
masterfrom
feature/join-151-zetkin-people-listing-and-tag-helpers
Open

conatus wants to merge 15 commits into
masterfrom
feature/join-151-zetkin-people-listing-and-tag-helpers

Conversation

@conatus

@conatus conatus commented Sep 21, 2026

Copy link
Copy Markdown
Member

Part of JOIN-151.

GMTU's existing membership needs re-tagging after a branch reorganisation, across both Zetkin and Mailchimp. Neither service exposes what a bulk job needs: a way to walk the membership, and tag writes that report failure per member rather than throwing, so one bad record does not stop the run.

The re-tag command itself lives in the GMTU add-on (gmtu#11). Nothing here changes existing behaviour.

Zetkin

Method Returns
listPeople($page, $perPage) One page of people, empty when exhausted or unconfigured
getPersonTags($personId) That person's tags
findOrCreateTagByTitle($title) The tag record, or null if unconfigured
tryAddTagToPerson($personId, $tagId) TAG_OK / TAG_NOT_CONFIGURED / TAG_ERROR
tryRemoveTagFromPerson($personId, $tagId) as above, plus TAG_MISSING on a 404

Both services' try* pairs now share one contract: they report a TAG_* constant. Both sets of wire values are pinned by tests, since the GMTU add-on compares against them across a plugin boundary.

One assumption worth checking before the bulk run. TAG_MISSING is a Zetkin 404 on DELETE /people/{id}/tags/{tagId}, and the add-on treats it as success on the reading that the person simply did not carry that tag. A 404 could also mean the person or the tag no longer exists. In a re-tag run that is unlikely, since the walk listed the person seconds earlier, but a member deleted mid-run would be logged as a successful removal rather than a problem. Worth watching in the dry run rather than taking on trust. Mailchimp's TAG_NOT_FOUND is a different fact — the member is not in the audience at all — which is why the two are named differently.

Zetkin was searched for a person by email in four places — findPersonByEmail, updatePerson, addTag and removeTag — each repeating the POST, the error check and the filter down to exact matches. addTag and removeTag were otherwise near-identical forty-five line twins. searchPeopleByEmail now holds the search and setTagByEmail the shared body, so both are one-liners. Net 58 lines removed.

The search returns every exact match rather than the first, because addTag/removeTag tagged all of them while findPersonByEmail took the first. Both behaviours are preserved; nothing stops Zetkin holding duplicate emails.

The PUT and DELETE tag calls were also written inline twice in addPerson; they are now putPersonTag and deletePersonTag, shared with the new helpers. A 404 on delete is still not logged as an error.

As with Mailchimp, the log wording in addTag/removeTag changes: the messages varied by verb and preposition and parameterising them cost more than the duplication did. Nothing asserts on them.

Mailchimp

updateListMemberTags was being built in four places, each repeating the audience lookup, subscriber hash and payload shape. There is now one private updateMemberTags, used by signup(), addTag(), removeTag() and the new pair. A hook for extension plugins has one home rather than four.

Method Returns
isConfigured() bool
tryAddTag($email, $tag, $client = null) TAG_OK / TAG_NOT_FOUND / TAG_NOT_CONFIGURED / TAG_ERROR
tryRemoveTag($email, $tag, $client = null) same

addTag and removeTag were near-identical seventeen-line twins; they are now two-line wrappers over a shared setTagOrThrow, matching the shape of the new pair. Four public methods, two private error policies, one place the call is built.

tryAddTag/tryRemoveTag name the axis that actually differs, which is error policy. addTagToMember did not: it took an email and added a tag, exactly like addTag.

Their throwing contract is unchanged: the primitive does not catch, so they log and rethrow the original ClientException rather than re-wrapping it. External code catching it is unaffected.

One thing is not behaviour-preserving: their log wording. The old messages were parameterised on method name and preposition ("Added tag 'x' to", "Removed tag 'x' from"); threading those through cost more clarity than the duplication did. They now read Set Mailchimp tag 'x' to active for <email>. Nothing asserts on these strings, but anyone grepping logs for the old wording should know.

not_found comes from the 404 rather than a memberExists() pre-check, halving the API calls per member across a full walk.

Test seams and regression coverage

The email-based tag paths in both services run during joins and Stripe webhooks but had no coverage: getZetkinContext() performs a live OAuth exchange and getClient() builds a real Mailchimp client. Two seams fix that — overrideZetkinContext() stands in for the OAuth exchange, and addTag/removeTag/memberExists take the same optional injected client the try* pair already had. getTags and findOrCreateTag now reuse the caller's client instead of constructing a fresh Guzzle client per call.

Twenty-six regression tests pin what the callers rely on, including: addTag tags every exact email match (Zetkin can hold duplicates) and ignores fuzzy near-misses; a missing person is a warning and a no-op; Zetkin failures are logged and swallowed while Mailchimp rethrows the original exception; removing an absent tag is a non-event; listPeople sends p/pp; findOrCreateTagByTitle reuses existing tags rather than creating duplicates.

The seam earned its keep immediately: the first run caught findOrCreateTagByTitle failing to destructure the client it passes along, which would have been a fatal on first production use.

Review notes

  • Zetkin's paging parameters (p, pp) are pinned by test as to what we send, but remain unverified against the live API. The add-on guards against a walk that never advances rather than trusting them.
  • listPeople opens its own Zetkin context per call, so a full walk costs one OAuth exchange per page.
  • getZetkinContext() returning an array destructured identically at many call sites is ugly; several predate this PR. Worth a follow-up rather than bundling here.
  • signup() applies ck_join_flow_add_tags and ck_join_flow_mailchimp_add_tags; the direct tag methods apply no filters. A new hook at the primitives would cover every path, but that existing inconsistency is untouched and may want its own ticket.
  • Correction to something I said earlier on this PR: I described addTag and removeTag as byte-for-byte unchanged. Their behaviour is, but their signatures are not — both, and memberExists, gained an optional trailing $client for the test seam. Backwards compatible for every existing caller, but not the "no signature change" I claimed.
  • overrideZetkinContext() is mutable static state on a class used during live signups. It is public, documented as a test seam and reset in tearDown. The alternative, threading a client through every public method, was larger than this PR should carry, but it is a fair thing to object to.
  • Nothing structural protects the contract between this plugin and the add-on. The pinned wire values catch a constant's value drifting and the method_exists guard catches a method disappearing, but neither would catch a return type changing — which is exactly what the bool to TAG_* change here was. That was caught by a test written on purpose, not by the type system. It is the real residual risk of two separately deployed plugins sharing a string protocol.

Testing

193 passing. MailchimpServiceTagsTest covers both tag states, the md5-of-lowercased-email subscriber hash, 404 versus other client errors versus unexpected throwables, and the unconfigured path making no call. The TAG_* values are pinned by a test because the add-on compares against them across a plugin boundary.

Written test-first and reverse-tested: collapsing not_found into error, sending active on remove, not lowercasing the hash, or making the primitive swallow exceptions each fail the suite.

Merge order

Merge, then push the version tag manuallyrelease-plugin.yml fires on a tag, not on merge. gmtu#11 follows; it refuses to run against an older parent.

🤖 Generated with Claude Code

Bulk maintenance jobs need to walk the membership and adjust tags, which
the service could not do: addTag and removeTag work one email at a time
and re-authenticate, re-search and re-fetch the full tag list on every
call.

Adds listPeople, getPersonTags, findOrCreateTagByTitle, addTagToPerson
and removeTagFromPerson, all built on the existing getZetkinContext.

The PUT and DELETE calls that apply and remove a person's tag now have a
single implementation each, which signup, addTag and removeTag all share.
Their log wording is unchanged, including treating a 404 on delete as
"tag does not exist" rather than an error.
@linear-code

linear-code Bot commented Sep 21, 2026

Copy link
Copy Markdown

JOIN-151

conatus and others added 2 commits September 21, 2026 11:17
GMTU still use Mailchimp alongside Zetkin, so the branch re-tagging job
has to fix both. The existing addTag and removeTag are built for a single
signup: they return nothing and throw on any API error. In a bulk run
that is unusable, because "this member is not in the audience" has to be
distinguishable from "Mailchimp is broken", and neither should stop the
walk.

addTagToMember and removeTagFromMember return ok, not_found,
not_configured or error. isConfigured lets a job ask once at the start
whether Mailchimp is worth talking to at all.

Not found is read from the 404 rather than pre-checked with
memberExists, which halves the API calls per member. Across a whole
membership walk that is the difference between one round trip and two.

The client is injectable so this is testable without network access.
The old addTag and removeTag are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@conatus conatus changed the title Add Zetkin people listing and person-tag helpers Add Zetkin and Mailchimp bulk tag helpers Sep 21, 2026
@conatus conatus changed the title Add Zetkin and Mailchimp bulk tag helpers Add Zetkin and Mailchimp bulk tag helpers for branch re-tagging Sep 21, 2026

@conatus conatus left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels to me we need to think a bit more about this before merging it.

Comment thread packages/join-block/src/Services/MailchimpService.php Outdated
Comment thread packages/join-block/src/Services/MailchimpService.php Outdated
Comment thread packages/join-block/src/Services/MailchimpService.php Outdated
Comment thread packages/join-block/src/Services/ZetkinService.php Outdated
Comment thread packages/join-block/src/Services/ZetkinService.php Outdated
Comment thread packages/join-block/src/Services/ZetkinService.php
conatus and others added 2 commits September 21, 2026 11:53
Review feedback on #118.

Drops the @SInCE tags and the @param/@return docblocks from the new
helpers. Nothing else in this repo documents that way: @SInCE appears
nowhere on master and the services carry only a handful of @PARAM lines
between them. Introducing the convention in one PR makes the codebase
less consistent, not more. The GMTU add-on is the opposite case, it uses
@SInCE almost everywhere, so its docblocks stay as they are.

The substance of those docblocks is kept as plain comments, because the
non-obvious parts still need saying: that listPeople costs one OAuth
exchange per page, that a tag the person does not have counts as
success, and that Mailchimp's 404 is read from the exception rather than
pre-checked.

Log messages now name the service that failed, matching how the rest of
ZetkinService already words them. "Could not tag person 42 with tag 7"
becomes "... in Zetkin".

The two Mailchimp failure paths were worded identically, so a log could
not tell them apart. One is now "Mailchimp rejected ..." for an API
rejection carrying a response body, the other "Could not reach Mailchimp
..." for a call that never completed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #118.

I argued against this on the thread on the grounds that it would be a
convention introduced in one PR. That was wrong: I checked for enums and
concluded from their absence, without checking for constants. Both repos
already do exactly this. Settings has GET_ADDRESS_IO and IDEAL_POSTCODES,
JoinService has CRM_RETRY_OPTION_PREFIX, and the GMTU add-on models
membership standing as STANDING_GOOD, STANDING_LAPSING and so on. So this
matches existing practice rather than starting something.

MailchimpService::TAG_OK, TAG_NOT_FOUND, TAG_NOT_CONFIGURED and
TAG_ERROR. The values are unchanged, so nothing on the wire moves.

These cross a plugin boundary into the GMTU add-on, which compares
against them, so a value change there breaks re-tagging silently rather
than loudly. testStatusValuesAreStable pins the values for that reason:
renaming a constant is free, changing what it holds is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@conatus
conatus requested a review from joaquimds September 21, 2026 11:32
@conatus conatus self-assigned this Sep 21, 2026
Comment thread packages/join-block/src/Services/MailchimpService.php Outdated
conatus and others added 4 commits September 21, 2026 14:26
Review feedback from @joaquimds on #118, and he is right on both counts.

addTagToMember was not meaningfully distinguishable from addTag: same
operation, same target, same argument list. The axis that actually
differs is error policy, and the name said nothing about it. They are
now tryAddTag and tryRemoveTag, which name the difference.

The duplication was worse than the two methods he flagged.
updateListMemberTags was being constructed in four places in this file,
each repeating the audience lookup, the subscriber hash and the payload
shape. That is the hazard he identified: add a hook for extension
plugins and you have to remember all four, or the bulk path silently
stops honouring a filter the signup path honours.

There is now one private updateMemberTags that builds the call, and it
deliberately does not catch. Callers pick their error policy: addTag and
removeTag log and rethrow exactly as before, the try* pair translates to
a TAG_* status.

That direction matters. Catching in the primitive and returning a status
would force the throwing callers to synthesise a new exception, and any
external code doing catch (ClientException) would stop catching. My
first sketch had it the wrong way round.

No deprecation, no signature change, no behaviour change to anything
that already existed. signup() moves across separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last of the four construction sites. This is the one where behaviour
could have shifted, so it is on its own commit to make the diff easy to
read.

Everything the call depends on is unchanged: the audience comes from the
same Settings lookup, the subscriber hash is the same md5 of the
lowercased email, the payload is the array signup already built, and the
existing client is passed in rather than a second one being made. It
stays inside signup's try/catch, which swallows a ClientException on the
grounds that tag updates are not critical for an existing member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comments on the new helpers had drifted into narrating the review
discussion rather than explaining the code. Why the primitive does not
catch, what would break if it did, why one log message is worded
differently from another: that belongs in the pull request, not in the
file.

What is left is the bits a reader cannot get from the code itself: that
this is the single place a tag write is built, that Mailchimp switches a
tag between active and inactive rather than deleting it, and that
listPeople costs an OAuth exchange per page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit unified where the API call is built but left the two
throwing methods as near-identical seventeen-line twins, differing only
in active/inactive and the wording of three log lines. That is the
duplication the review actually pointed at, and the fix for that shape
was already sitting ten lines above in trySetTag.

setTagOrThrow now holds it once and addTag and removeTag are two-line
wrappers, matching tryAddTag and tryRemoveTag. Four public methods, two
private error policies, one place the call is built.

Log wording changes, which is the one thing here that is not
behaviour-preserving. The messages were parameterised on the method name
and the preposition ("Added tag 'x' to", "Removed tag 'x' from"), and
threading those through would have cost more clarity than the
duplication did. They now read "Set Mailchimp tag 'x' to active for
<email>". Nothing asserts on these strings, but anyone grepping logs for
the old wording should know.

The exception contract is untouched: the original ClientException is
still rethrown, not re-wrapped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@conatus

conatus commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

@joaquimds

Can you check this out and see if the robot scratched your particular itch?

@joaquimds

Copy link
Copy Markdown
Member

The Zetkin service has the same issue, actually... I don't really care... up to you @conatus

The review points about MailchimpService applied here too, and the
duplication was worse.

Zetkin was searched for a person by email in four places:
findPersonByEmail, updatePerson, addTag and removeTag, each repeating
the POST, the error check and the filter down to exact email matches.
addTag and removeTag were otherwise near-identical forty-five line
twins. searchPeopleByEmail now holds the search, and setTagByEmail holds
the shared body, so addTag and removeTag are one-liners.

It returns every exact match rather than the first, because addTag and
removeTag tagged all of them and findPersonByEmail took the first.
Both behaviours are preserved, and nothing stops Zetkin holding
duplicate emails.

addTagToPerson and removeTagFromPerson are now tryAddTagToPerson and
tryRemoveTagFromPerson, matching the Mailchimp naming: the try prefix
marks the pair that reports rather than logging and swallowing. The
older names had the same problem as addTagToMember, in that addTag also
adds a tag to a person.

Log wording changes in the same way it did for Mailchimp: the messages
varied by verb and preposition and parameterising them all cost more
than the duplication did. Nothing asserts on them.

Net 58 lines removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@conatus
conatus requested a review from joaquimds September 21, 2026 14:30
conatus and others added 3 commits September 21, 2026 15:51
The email-based tag paths in both services run during joins and Stripe
webhooks but had no coverage, because getZetkinContext() performs a live
OAuth exchange and getClient() builds a real Mailchimp client. Every
refactor of them so far has been verified by reading the diff.

ZetkinService gains overrideZetkinContext(), a test seam that stands in
for the OAuth exchange. MailchimpService's addTag, removeTag and
memberExists gain the same optional injected client the try* pair
already had. getTags and findOrCreateTag now take the caller's client
instead of constructing a fresh Guzzle client per call, which they did
even when the caller was holding one.

Twenty regression tests pin what the callers rely on: addTag tags every
exact email match, not the first, and ignores Zetkin's fuzzy near
misses; a missing person is a warning and a no-op; an API failure is
logged and swallowed on Zetkin and rethrown as the original exception on
Mailchimp; removing an absent tag is a non-event; listPeople sends p and
pp; findOrCreateTagByTitle reuses an existing tag rather than creating a
duplicate.

The new Zetkin tests immediately caught a bug in this very change:
findOrCreateTagByTitle was not destructuring the client it now passes
along, which would have been a fatal on first use in production. That is
the argument for the seam in one sentence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two services' try pairs had the same prefix and different contracts:
Mailchimp returned TAG_* strings, Zetkin returned bools. Same word, two
meanings, and the bool could not distinguish "the tag was not there"
from "the delete succeeded", which the re-tag job's reporting cares
about.

Both now report TAG_* constants. Zetkin has TAG_MISSING where Mailchimp
has TAG_NOT_FOUND because they name different facts: a Zetkin 404 on
delete means the person did not carry the tag, a Mailchimp 404 means the
member is not in the audience at all.

The wire values are pinned by a test for the same reason as Mailchimp's:
the GMTU add-on compares against them across a plugin boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1.4.38 was never released on its own; both sets of helpers go out
together as 1.4.39, so a changelog entry for a version nobody can
install is noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants