fix(refs): resolve unjoined-but-public channels by name#249
Conversation
resolveChannelRef's name branch called client.channels.getChannels({ workspaceId }),
which is membership-scoped and excludes public channels the current user hasn't
joined. Acting on such a channel by name failed with CHANNEL_NOT_FOUND while the
id: and URL forms worked, because getChannel(id) isn't membership-filtered.
Merge getChannels with workspaces.getPublicChannels and dedupe by id so joined
and unjoined-but-public channels resolve consistently. Same shape as the
--scope public path in src/commands/channel/list.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@doistbot /review |
doistbot
left a comment
There was a problem hiding this comment.
This PR successfully expands name-based channel resolution to include unjoined public channels, fixing a frustrating gap in how the CLI handles larger workspaces. The approach is solid and well-tested, ensuring users can interact with any public channel regardless of their membership status. A few refinements are noted regarding aligning the deduplication logic with existing codebase patterns, deferring the public channel API fetch until necessary to reduce overhead, and tweaking the test suite to better prove deduplication without hardcoding the underlying fetch strategy.
| client.channels.getChannels({ workspaceId }), | ||
| client.workspaces.getPublicChannels(workspaceId), | ||
| ]) | ||
| const byId = new Map<number, Channel>() |
There was a problem hiding this comment.
[P3] This logic introduces a new Map-based approach for deduplication. src/commands/channel/list.ts handles this exact same joined/public intersection by using a Set of joined IDs. Aligning with that existing pattern reduces drift and simplifies the code:
const joinedIds = new Set(joined.map((channel) => channel.id))
const channels = [
...joined,
...publicChannels.filter((channel) => !joinedIds.has(channel.id))
]| // fail with CHANNEL_NOT_FOUND even though the channel is discoverable. Merge with | ||
| // getPublicChannels (workspace-scoped, returns all public channels regardless of | ||
| // membership) and dedupe by id so a joined-and-public channel doesn't match twice. | ||
| const [joined, publicChannels] = await Promise.all([ |
There was a problem hiding this comment.
[P2] This Promise.all makes every name-based channel lookup depend on workspaces.getPublicChannels, even when the ref is an exact match in the joined list. That widens the failure surface for common lookups and adds a full public-channel fetch to all resolveChannelRef() callers. A better fit here is to resolve against getChannels({ workspaceId }) first and only fetch/merge getPublicChannels(workspaceId) when the joined list alone can't produce an exact match.
| const result = await resolveChannelRef('general', 1) | ||
|
|
||
| expect(mockGetChannels).toHaveBeenCalledWith({ workspaceId: 1 }) | ||
| expect(mockGetPublicChannels).toHaveBeenCalledWith(1) |
There was a problem hiding this comment.
[P3] This assertion hard-codes the current Promise.all fetch strategy. If resolveChannelRef() is later optimized to return after an exact joined-channel match, this test will fail even though the behavior is still correct. The result assertion already covers the contract here; drop the getPublicChannels call expectation.
| expect(result).toEqual(publicCh) | ||
| }) | ||
|
|
||
| it('deduplicates channels appearing in both joined and public lists', async () => { |
There was a problem hiding this comment.
[P2] This test doesn't actually prove deduplication. matchByName() returns the first exact case-insensitive match before it checks for ambiguity, so this still passes if the same channel appears twice in the merged list. Use a non-exact query (for example, a substring match) so the test would fail without the dedupe step.
Summary
resolveChannelRef's name branch only calledclient.channels.getChannels({ workspaceId }), which is membership-scoped and excludes public channels the current user hasn't joined.tw channel <action> "Public Channel I Haven't Joined"fails withCHANNEL_NOT_FOUND, whileid:Nand URL refs work becausegetChannel(id)isn't membership-filtered.getChannelswithworkspaces.getPublicChannelsand dedupes by id. Same shape assrc/commands/channel/list.ts:177-180already uses for the--scope publicpath.Why this exists
The bug was first suspected to be "active vs archived" — that turned out to be wrong:
getChannels({ workspaceId })already returns both states (Twist's API is tri-state — seelist.ts:163-169). The real axis is joined vs unjoined, which I confirmed live and which also holds for non-admin users (getPublicChannelsis not admin-gated). Scale check in Doist workspace 1585: 998 public channels, 829 of which I haven't joined — so the bug bites in production, not just edge cases.Test plan
npm run lint— cleannpm run type-check— no errors inrefs.ts/refs.test.ts(pre-existing@doist/cli-coreexport errors elsewhere onmainare unrelated)npx vitest run src/lib/refs.test.ts— 71/71 pass (4 new tests: unjoined-public exact match, unjoined-public substring match, dedup of joined-and-public, ambiguity across joined+public)tw channel create tw-cli-resolver-smoke-<ts> --workspace 1767→ id 837258removeUsertw channel archive "tw-cli-resolver-smoke-<ts>" --workspace 1767→{ id: 837258, archived: true }✅ (previously:CHANNEL_NOT_FOUND)tw channel delete id:837258 --workspace 1767 --yes→ cleaned upSmoke required #246's
channel create/archive/deletecommands to exercise an action on the resolved channel; they were checked out temporarily and reverted after.Out of scope
tw channel unarchivein feat(channel): add create, delete, archive, unarchive subcommands #246'ssrc/commands/channel/index.ts— separate trivial follow-up once both this and feat(channel): add create, delete, archive, unarchive subcommands #246 land.id:and URL branches — already work for archived and unjoined channels viagetChannel(id).🤖 Generated with Claude Code