feat(channel): channel membership control (members/add/remove/sync)#244
Conversation
Parse mixed user / group:<ref> lists for channel membership commands. Groups expand to their current userIds at call time (one-shot, not a persistent link); the caveat is surfaced later in command help. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/lib/api.ts: wrap channels.addUsers / removeUsers with spinner messages and helpers (addUsersToChannel / removeUsersFromChannel). - src/commands/channel/members.ts: list members + groups-fully-in-channel hint, add/remove with group: expansion, sync with dry-run-by-default, --apply, and --include-self self-removal guard. - src/commands/channel/index.ts: register the four subcommands with examples and the one-shot group-expansion caveat in --help. - src/commands/channel/members.test.ts: 15 tests covering the cases in CHANNEL-MEMBERSHIP-PLAN.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Closing while work is in progress; will reopen when ready for review. |
Add Quick Reference rows and a Notes paragraph explaining the group: ref expansion (one-shot, not a persistent link), sync's dry-run default, and --include-self. Regenerated skills/twist-cli/SKILL.md. Also pulls a fresh npm install (resolves prior @doist/cli-core/auth type-check errors on main). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without these, tw channel add/remove/sync (and any future channel mutations like create/delete/update) fail with INSUFFICIENT_SCOPE. Discovered while testing the new channel-membership commands locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
doistbot
left a comment
There was a problem hiding this comment.
This PR introduces comprehensive channel membership control to the CLI, adding commands to list, add, remove, and sync members using both user and one-shot group references. The feature provides a highly flexible and powerful tool for managing channel participation across the workspace. There are a few areas that need refinement, including updating batch API access to adhere to repository guidelines, replacing sequential network network waterfalls with concurrent fetching, extracting shared membership mutation logic, and expanding test coverage for the new reference parsers and output flags.
| const responses = await client.batch(...calls) | ||
| const map = new Map<number, WorkspaceUser>() | ||
| userIds.forEach((id, i) => { | ||
| const user = responses[i]?.data |
There was a problem hiding this comment.
[P1] AGENTS.md mandates that you should never access .data directly on a batch result, as this bypasses API error handling. Replace this manual loop with the buildOptionalBatchNameMap helper from src/lib/api.ts (e.g., return buildOptionalBatchNameMap(userIds, responses, 'user')).
| } | ||
|
|
||
| if (options.ndjson) { | ||
| console.log(formatNdjson([{ ...channel, members, groupsFullyInChannel: fullyInChannel }])) |
There was a problem hiding this comment.
[P2] --ndjson always emits the full channel object here, even without --full, while the --json path emits the slim { id, name, workspaceId, members, groupsFullyInChannel } payload by default. That makes the machine-readable contract depend on which flag the user picked and leaves no way to get non-full NDJSON output. Build the same default payload for NDJSON and only switch to the full object when options.full is set.
| throw new CliError('MISSING_USERS', 'Provide at least one user or group:<ref> reference.') | ||
| } | ||
|
|
||
| const userRefs: string[] = [] |
There was a problem hiding this comment.
[P2] Splitting refs into userRefs and groupRefs loses the original mixed order. For example, ['group:Backend', 'alice'] resolves as alice first and the group members second, so the resulting added/removed arrays and dry-run output no longer match the input order. If input-order dedupe is part of the contract, resolve the refs in a single pass (or preserve an ordered expansion list) instead of batching all users first.
| refs, | ||
| workspaceId, | ||
| ) | ||
| const requested = dedupe(requestedIds) |
There was a problem hiding this comment.
[P2] resolveChannelMemberRefs already deduplicates IDs internally via a Set before returning them. This dedupe call is redundant, and the local helper function can be removed entirely.
| ? `No new members added to "${channel.name}" (already in channel).` | ||
| : `No members removed from "${channel.name}" (none of the users were in channel).` | ||
|
|
||
| if (expandedFrom.length > 0) { |
There was a problem hiding this comment.
[P3] This logic for logging group expansions is duplicated exactly in syncChannelMembers at line 322. Consider extracting it to a shared helper function.
| } | ||
| } | ||
|
|
||
| if (userRefs.length > 0) { |
There was a problem hiding this comment.
[P2] User and group references are resolved sequentially, causing a network waterfall (one API call per group). Execute resolveUserRefs and all resolveGroupRef calls concurrently with Promise.all.
| vi.restoreAllMocks() | ||
| apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) | ||
| apiMocks.getTwistClient.mockResolvedValue({ | ||
| workspaceUsers: { getUserById: mockGetUserById }, |
There was a problem hiding this comment.
[P2] mockGetUserById is a bare vi.fn() without a return value, causing the implementation's getUserById calls to return undefined. Because mockBatch resolves blindly regardless of arguments, the construction of the batch requests is effectively untested. Consider having mockGetUserById return a dummy request object and asserting that mockBatch actually receives those objects.
| apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) | ||
| apiMocks.getTwistClient.mockResolvedValue({ | ||
| workspaceUsers: { getUserById: mockGetUserById }, | ||
| channels: { getChannel: mockGetChannel }, |
There was a problem hiding this comment.
[P2] mockGetChannel is wired up here but never exercised because the --full flag is completely untested across add, remove, and sync. Adding a test for --full will ensure the secondary API call and JSON formatting are properly verified.
| expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5]) | ||
| }) | ||
|
|
||
| it('expands group:<ref> and dedupes', async () => { |
There was a problem hiding this comment.
[P1] This test doesn't actually verify group expansion or dedupe. Both behaviors are precomputed in the mocked resolveChannelMemberRefs result, and there isn't any companion src/lib/refs.test.ts coverage for the new helper. Please add a focused helper-level test for mixed user + group: refs, input-order dedupe, and invalid group: input; otherwise the core feature added in this PR is protected only by mocks.
| }) | ||
| }) | ||
|
|
||
| describe('tw channel remove', () => { |
There was a problem hiding this comment.
[P2] The new remove subcommand only gets side-effect assertions here. Its separate --dry-run, --json, and --full branches aren't exercised at all, so regressions in those user-facing modes will slip through. Add at least one dry-run case and one machine-output case for remove.
- refs.ts: preserve input order across mixed user/group: refs; resolve user refs and all group refs concurrently in one Promise.all. - members.ts: use getOptionalBatchData (don't bypass batch error handling); drop redundant dedupe (resolveChannelMemberRefs already dedupes via a Set); parallelize independent calls in mutate, list, and sync; run add+remove concurrently in sync; ndjson now honours --full like json does (slim default, raw on --full); extract a small logExpansion helper used by both mutate and sync. - members.test.ts: tighten mockGetUserById to return tagged requests so we can assert batch() composition; add tests for --full, --ndjson, remove --dry-run, remove --json, remove --full. - refs.test.ts: add helper-level coverage for resolveChannelMemberRefs (mixed order, dedupe across users + group expansion, empty group: rejection, case-insensitive prefix). Deferred (out of scope, will reply on PR): extracting a shared membership-mutation flow between groups/members.ts and channel/members.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
36deeab to
3f3652f
Compare
|
Addressed doistbot's feedback in 02c9f04..3f3652f (squashed mentally: review fixes + chore to untrack a lock file). Done:
Deferred ([P2] I see the duplication and agree it'll drift. I'm punting it to a follow-up PR for two reasons:
Happy to open the follow-up immediately after this lands. Let me know if you'd rather I roll it in here. |
scottlovegrove
left a comment
There was a problem hiding this comment.
A couple of big changes required, once they are done, I'll go through the PR again.
Per @scottlovegrove's review feedback. Each subcommand now lives in its own file, mirroring the existing channel/ folder pattern (list.ts, threads.ts, helpers.ts): - src/commands/channel/membership-helpers.ts: shared bits used by add, remove, sync, and the members list view (channelUserIds, fetchUsersByIds, logExpansion, describeExpansion, groupsFullyInChannel, mutateChannelMembership). - src/commands/channel/members.ts: now only the list handler. - src/commands/channel/add.ts, remove.ts: thin wrappers around mutateChannelMembership. - src/commands/channel/sync.ts: the sync handler (kept here because of the self-removal guard and dry-run-by-default behaviour). - src/commands/channel/index.ts: imports updated. Tests split to match: - members.test.ts: only the list tests. - add.test.ts, remove.test.ts, sync.test.ts: one file per command, each with its own minimal mock setup. 677 tests still passing (43 test files, +3 from the split). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Done in 0c40d20 (@scottlovegrove). Code split —
I considered keeping the shared helpers inside the existing Tests split — one file per command:
677 tests still passing (43 test files, +3 from the split). Type-check and lint clean. Ready for your second pass when you've got a moment. |
scottlovegrove
left a comment
There was a problem hiding this comment.
I'm still not 100% sold on the API surface of a lot of the commands here.
tw channel add- Sounds like a command you'd use to add a new channel, whereas we are using it for adding memberstw channel sync- Sync what? It's not immediately apparent what is going on unless you do the help flag. This could even betw channel members set.tw channel remove- Similar to add, sounds like the command you'd use to delete the channel.
I think a lot of the member-related ones should be sub-commands of the members command, ensures the context is right.
| tw channel add <ref> id:123 --dry-run # Preview the diff | ||
| tw channel remove <channel-ref> <ref...> # Remove users and/or group:<ref> | ||
| tw channel sync <channel-ref> <ref...> # Replace membership; dry-run by default | ||
| tw channel sync <ref> group:Squad --apply # Mutate. Refuses to remove self unless --include-self |
There was a problem hiding this comment.
| tw channel sync <ref> group:Squad --apply # Mutate. Refuses to remove self unless --include-self | |
| tw channel sync <channel-ref> group:Squad --apply # Mutate. Refuses to remove self unless --include-self |
Let's keep this consistent with the line above
There was a problem hiding this comment.
What changed on this file? Should be nothing, right? Ask your agent to reset this file.
| export interface ChannelMemberRefs { | ||
| userIds: number[] | ||
| expandedFrom: { groupId: number; groupName: string; userIds: number[] }[] | ||
| } |
There was a problem hiding this comment.
Prefer type instead of interface.
Restructure the membership commands as nested sub-commands of `members`
so the context is explicit, and rename `sync` to `set`:
tw channel members <ref> # list (default)
tw channel members add <ref> ...
tw channel members remove <ref> ...
tw channel members set <ref> ... # was: sync
Reasoning: `tw channel add` / `remove` / `sync` sounded like
channel-level operations (creating a channel, deleting it, syncing
something unclear). Nesting under `members` makes intent unambiguous,
and `set` reads as the replacement semantic better than `sync`.
`members` is now a pure command group with `list` as its
`{ isDefault: true }` subcommand, so `tw channel members <ref>`
continues to list (default action) without options bleeding between
the parent and the child mutate subcommands.
Also:
- src/lib/refs.ts: switch `interface ChannelMemberRefs` to `type` per
repo preference.
- src/lib/skills/content.ts: fix `<ref>` → `<channel-ref>` for
consistency with the line above it; update the Quick Reference and
notes to reflect the new command structure. Regenerated SKILL.md.
- package-lock.json: reset to main; the previous diff was an
unintentional version bump from `npm i` and shouldn't have been
committed.
- src/commands/channel/sync.ts → set.ts (with handler and option
type renamed accordingly); sync.test.ts → set.test.ts; tests in
add/remove/set updated to invoke the new nested paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Addressed in fc5dda2 (@scottlovegrove): Naming + nesting. Commands now live under
I went with Inline fixes.
677 tests still passing, type-check + lint clean. Smoke-tested Re-requesting your review when you've got a moment. |
The previous reset used my local `main` ref which was stale; the resulting lockfile was inconsistent with package.json's bumped `@doist/cli-core` and `@doist/twist-sdk` versions, so CI's `npm ci` rejected it across lint, test, and SKILL.md Sync jobs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scottlovegrove
left a comment
There was a problem hiding this comment.
Just one small nit that needs sorting, but other than that, approving.
| const workspaceId = await getCurrentWorkspaceId() | ||
| const [channel, sessionUser, memberRefs] = await Promise.all([ | ||
| resolveChannelRef(channelRef, workspaceId), | ||
| getSessionUser() as Promise<User>, |
There was a problem hiding this comment.
The casting of Promise<User> is not required.
| getSessionUser() as Promise<User>, | |
| getSessionUser(), |
## [2.43.0](v2.42.0...v2.43.0) (2026-05-22) ### Features * **channel:** channel membership control (members/add/remove/sync) ([#244](#244)) ([84ea32b](84ea32b))
|
🎉 This PR is included in version 2.43.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
Adds per-user and per-group membership control to
tw channel. Follows the plan in docs/CHANNEL-MEMBERSHIP-PLAN.md, with the command surface revised in review.tw channel members add general luis@doist.com group:Design id:789.group:<ref>is one-shot expansion — adds the group's current members at call time; the group is not persistently linked to the channel, and users added later to the group will not auto-join. Surfaced in--helpforaddandset.setreplaces the channel's membership with the resolved set. Dry-run by default; pass--applyto mutate.setrefuses to remove the acting user unless--include-selfis also passed.What's in the PR
One file per command, mirroring the rest of
src/commands/channel/(list.ts,threads.ts, etc.):resolveChannelMemberRefshandlesgroup:prefix; reusesresolveUserRefs+resolveGroupRef; preserves input order across mixed refs; resolves user and group lookups concurrentlyaddUsersToChannel/removeUsersFromChannel+ spinner messagesmutateChannelMembership,fetchUsersByIds(viagetOptionalBatchData),logExpansion,groupsFullyInChannellistChannelMembersmutateChannelMembership--apply,--include-selfguardmembersis a command group;listis its{ isDefault: true }subcommandresolveChannelMemberRefschannels:writeandchannels:removetoREAD_WRITE_SCOPES(see side discoveries)CHANGELOG is not edited — semantic-release will generate it from the conventional-commit messages on the next release.
End-to-end test
Ran against the real Doist workspace (ws:1585) on a throwaway channel "Testing Group Setting Via CLI" (id:837185), with group
id:32017"25Q3|Engineering: Resharding" (3 users, none of them me (Luis)) and userid:252356Ben (not in the group, not me (Luis)):tw channel members <ch>tw channel members add <ch> group:32017 id:252356 --dry-runtw channel members add <ch> group:32017 id:252356tw channel members <ch>tw channel members remove <ch> id:252356tw channel members set <ch> id:252356INVALID_VALUE(would remove me (Luis)) ✓tw channel members set <ch> id:252356 id:447642tw channel members set <ch> … --applytw channel members remove <ch> id:252356Channel was deleted afterwards via the Twist UI.
Side discoveries
Found while developing/testing — flagged so reviewers know they exist:
channels:writeandchannels:removewere missing fromREAD_WRITE_SCOPESin src/lib/auth-provider.ts. Without them every channel mutation returnedINSUFFICIENT_SCOPE. Existing logged-in users will need to re-runtw auth loginto pick up the new scopes.tw channel createandtw channel deletedon't exist in the CLI despite the SDK exposing both. End-to-end testing required creating/deleting the channel in the Twist UI. Worth adding to mirrortw groups create/delete.tw usersappears to return users outside the current workspace. Test User1 (id:2342) showed up intw usersoutput but the channel API rejected the add withUser 2342 is not part of workspace 1585. The list scope oftw userslikely needs a workspace filter.Review history
groups/members.ts); see response.members; renamesync→set; switchinterface→typeonChannelMemberRefs; fix<ref>→<channel-ref>in skill docs; reset straypackage-lock.jsondiff — addressed in fc5dda2 (plus a follow-up lockfile fix after the initial reset used a stale localmain).Checks
npm run type-checknpm run lintnpm test(677 passing, including new helper-level tests forresolveChannelMemberRefs)npm run build && npm run sync:skill