Skip to content

fix(refs): resolve unjoined-but-public channels by name#249

Open
lmjabreu wants to merge 1 commit into
mainfrom
lmjabreu/channel-ref-finds-public-channels
Open

fix(refs): resolve unjoined-but-public channels by name#249
lmjabreu wants to merge 1 commit into
mainfrom
lmjabreu/channel-ref-finds-public-channels

Conversation

@lmjabreu
Copy link
Copy Markdown
Contributor

Summary

  • resolveChannelRef's name branch only called client.channels.getChannels({ workspaceId }), which is membership-scoped and excludes public channels the current user hasn't joined.
  • Effect: tw channel <action> "Public Channel I Haven't Joined" fails with CHANNEL_NOT_FOUND, while id:N and URL refs work because getChannel(id) isn't membership-filtered.
  • Fix merges getChannels with workspaces.getPublicChannels and dedupes by id. Same shape as src/commands/channel/list.ts:177-180 already uses for the --scope public path.

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 — see list.ts:163-169). The real axis is joined vs unjoined, which I confirmed live and which also holds for non-admin users (getPublicChannels is 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 — clean
  • npm run type-check — no errors in refs.ts / refs.test.ts (pre-existing @doist/cli-core export errors elsewhere on main are unrelated)
  • npx vitest run src/lib/refs.test.ts71/71 pass (4 new tests: unjoined-public exact match, unjoined-public substring match, dedup of joined-and-public, ambiguity across joined+public)
  • Live smoke in workspace 1767 (Testing Environment), against a fresh test channel — no existing channels touched:
    1. tw channel create tw-cli-resolver-smoke-<ts> --workspace 1767 → id 837258
    2. Left the channel via SDK removeUser
    3. tw channel archive "tw-cli-resolver-smoke-<ts>" --workspace 1767{ id: 837258, archived: true } ✅ (previously: CHANNEL_NOT_FOUND)
    4. tw channel delete id:837258 --workspace 1767 --yes → cleaned up

Smoke required #246's channel create/archive/delete commands to exercise an action on the resolved channel; they were checked out temporarily and reverted after.

Out of scope

🤖 Generated with Claude Code

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 doistbot requested a review from Bloomca May 23, 2026 08:27
@scottlovegrove
Copy link
Copy Markdown
Collaborator

@doistbot /review

Copy link
Copy Markdown
Member

@doistbot doistbot left a comment

Choose a reason for hiding this comment

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

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.

Share FeedbackReview Logs

Comment thread src/lib/refs.ts
client.channels.getChannels({ workspaceId }),
client.workspaces.getPublicChannels(workspaceId),
])
const byId = new Map<number, Channel>()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[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))
]

Comment thread src/lib/refs.ts
// 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([
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[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.

Comment thread src/lib/refs.test.ts
const result = await resolveChannelRef('general', 1)

expect(mockGetChannels).toHaveBeenCalledWith({ workspaceId: 1 })
expect(mockGetPublicChannels).toHaveBeenCalledWith(1)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[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.

Comment thread src/lib/refs.test.ts
expect(result).toEqual(publicCh)
})

it('deduplicates channels appearing in both joined and public lists', async () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[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.

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.

3 participants