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.
- Switch from Map-based dedup to the Set<joinedId>.filter() pattern already used in src/commands/channel/list.ts so the two name-merge sites stay in step. - Drop call-shape assertions from the exact-match test; result coverage is enough and call expectations would lock in the fetch strategy. - Switch the dedup test to a substring query. matchByName's exact-match branch returns on the first .find, so an exact query passes whether dedup works or not. A substring query exercises the partial-match counting and fails without dedup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bloomca
left a comment
There was a problem hiding this comment.
The code change looks good. I do agree that it can add a bit of noise (in general I assume most of the time we want to look at joined channels), but let's try and see.
|
Thanks @Bloomca. Agreed it's worth watching. If the substring noise shows up on the bigger workspaces, the lazy-fetch fallback doistbot suggested (joined first, widen to public only on a miss) is the natural next step. Merging. |
|
🎉 This PR is included in version 2.44.2 🎉 The release is available on: Your semantic-release bot 📦🚀 |
* fix(refs): resolve unjoined-but-public channels by name resolveChannelRef name-branch only called getChannels (membership-scoped), so `tdc channel <action> "Public Channel I Haven't Joined"` failed with CHANNEL_NOT_FOUND. Merge with workspaces.getPublicChannels and dedupe by id — same shape as src/commands/channel/list.ts. Ports Doist/twist-cli#249. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: defer getPublicChannels + harden dedupe test - Skip workspaces.getPublicChannels when an exact match already exists in the joined list. Common case (user types channel they're in) avoids the workspace-wide call. - Dedupe test now uses distinct object instances with the same id, so it proves dedupe-by-id rather than reference equality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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