Conversation
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.
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>
…tkin-people-listing-and-tag-helpers
conatus
commented
Sep 21, 2026
conatus
left a comment
Member
Author
There was a problem hiding this comment.
Feels to me we need to think a bit more about this before merging it.
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>
joaquimds
requested changes
Sep 21, 2026
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>
Member
Author
|
Can you check this out and see if the robot scratched your particular itch? |
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
listPeople($page, $perPage)getPersonTags($personId)findOrCreateTagByTitle($title)nullif unconfiguredtryAddTagToPerson($personId, $tagId)TAG_OK/TAG_NOT_CONFIGURED/TAG_ERRORtryRemoveTagFromPerson($personId, $tagId)TAG_MISSINGon a 404Both services'
try*pairs now share one contract: they report aTAG_*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_MISSINGis a Zetkin 404 onDELETE /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'sTAG_NOT_FOUNDis 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,addTagandremoveTag— each repeating the POST, the error check and the filter down to exact matches.addTagandremoveTagwere otherwise near-identical forty-five line twins.searchPeopleByEmailnow holds the search andsetTagByEmailthe shared body, so both are one-liners. Net 58 lines removed.The search returns every exact match rather than the first, because
addTag/removeTagtagged all of them whilefindPersonByEmailtook 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 nowputPersonTaganddeletePersonTag, shared with the new helpers. A 404 on delete is still not logged as an error.As with Mailchimp, the log wording in
addTag/removeTagchanges: the messages varied by verb and preposition and parameterising them cost more than the duplication did. Nothing asserts on them.Mailchimp
updateListMemberTagswas being built in four places, each repeating the audience lookup, subscriber hash and payload shape. There is now one privateupdateMemberTags, used bysignup(),addTag(),removeTag()and the new pair. A hook for extension plugins has one home rather than four.isConfigured()booltryAddTag($email, $tag, $client = null)TAG_OK/TAG_NOT_FOUND/TAG_NOT_CONFIGURED/TAG_ERRORtryRemoveTag($email, $tag, $client = null)addTagandremoveTagwere near-identical seventeen-line twins; they are now two-line wrappers over a sharedsetTagOrThrow, matching the shape of the new pair. Four public methods, two private error policies, one place the call is built.tryAddTag/tryRemoveTagname the axis that actually differs, which is error policy.addTagToMemberdid not: it took an email and added a tag, exactly likeaddTag.Their throwing contract is unchanged: the primitive does not catch, so they log and rethrow the original
ClientExceptionrather 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_foundcomes from the 404 rather than amemberExists()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 andgetClient()builds a real Mailchimp client. Two seams fix that —overrideZetkinContext()stands in for the OAuth exchange, andaddTag/removeTag/memberExiststake the same optional injected client thetry*pair already had.getTagsandfindOrCreateTagnow 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:
addTagtags 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;listPeoplesendsp/pp;findOrCreateTagByTitlereuses existing tags rather than creating duplicates.The seam earned its keep immediately: the first run caught
findOrCreateTagByTitlefailing to destructure the client it passes along, which would have been a fatal on first production use.Review notes
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.listPeopleopens 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()appliesck_join_flow_add_tagsandck_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.addTagandremoveTagas byte-for-byte unchanged. Their behaviour is, but their signatures are not — both, andmemberExists, gained an optional trailing$clientfor 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 intearDown. 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.method_existsguard catches a method disappearing, but neither would catch a return type changing — which is exactly what thebooltoTAG_*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.
MailchimpServiceTagsTestcovers 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. TheTAG_*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_foundintoerror, sendingactiveon remove, not lowercasing the hash, or making the primitive swallow exceptions each fail the suite.Merge order
Merge, then push the version tag manually —
release-plugin.ymlfires on a tag, not on merge. gmtu#11 follows; it refuses to run against an older parent.🤖 Generated with Claude Code