From ba46b45688501bf6d7953c8db3704a00d42bcded Mon Sep 17 00:00:00 2001 From: Jacobo de Vera Date: Sat, 3 Oct 2026 14:33:20 +0100 Subject: [PATCH 1/6] feat(channel): support default thread audiences --- CHANGELOG.md | 6 + skills/comms-cli/SKILL.md | 10 +- src/commands/channel/channel.test.ts | 244 +++++++++++++++++++++++ src/commands/channel/create.ts | 30 ++- src/commands/channel/default-audience.ts | 74 +++++++ src/commands/channel/index.ts | 45 ++++- src/commands/channel/members.test.ts | 67 ++++++- src/commands/channel/members.ts | 30 ++- src/commands/channel/update.ts | 65 ++++-- src/lib/skills/content.ts | 10 +- 10 files changed, 548 insertions(+), 33 deletions(-) create mode 100644 src/commands/channel/default-audience.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cadb0c8..66a0c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Features + +- **channel:** add default audience groups, users, and clearing to create/update, with resolved dry-run previews and default audience details in members JSON. + ## [3.4.1](https://github.com/Doist/comms-cli/compare/v3.4.0...v3.4.1) (2026-09-21) ### Bug Fixes diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 0be66ce..7c5f375 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -260,6 +260,10 @@ tdc channels --ids-only # Output only channel IDs tdc channel create "Engineering" # Create a channel in the current workspace tdc channel create "Leadership Team" --private --users id:10,id:20 # Create private channel with initial members tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json # Create and return channel as JSON +tdc channel create "Squad" --default-groups "Design" --default-users alice@doist.com # Set default thread audience +tdc channel update --default-groups "Design,Backend" --dry-run # Preview resolved group IDs +tdc channel update --default-users id:10,alice@doist.com # Replace default user recipients +tdc channel update --clear-default-audience # Disable defaults and empty both lists tdc channel update "New name" # Rename a channel tdc channel update --name "New name" # Rename with an explicit flag tdc channel update --description "Team discussions" # Update channel description @@ -279,7 +283,7 @@ tdc channel threads --limit 20 --cursor # Paginate tdc channel threads --json # { results, nextCursor } with isUnread + url tdc channel threads --ids-only # Output only thread IDs; pagination notice goes to stderr tdc channel members # List a channel's members + groups fully in the channel -tdc channel members --json # JSON with id, name, workspaceId, members +tdc channel members --json # JSON with members, groupsFullyInChannel, and defaultAudience tdc channel members --ids-only # Output only member user IDs tdc channel members add alice group:Design # Add users and/or expand group: members tdc channel members add a@d.com id:789 --json # Add refs, output result as JSON @@ -314,6 +318,10 @@ If a channel is not found in `tdc channels`, widen with broader listings such as `tdc channel threads` returns every thread in the channel; pagination filters (`--limit`, `--cursor`, `--since`, `--until`, `--unread`) are applied client-side after fetch. `--archive-filter` is applied server-side. Results are sorted newest-first by last activity. In `--json` / `--ndjson`, the response includes a `nextCursor` string (opaque) you can pass via `--cursor` to fetch the next page; NDJSON emits the cursor as a final `{ "_meta": true, "nextCursor": "..." }` line. +Both `channel create` and `channel update` accept `--default-groups ` (comma-separated group IDs or names) and `--default-users ` (comma-separated user IDs, emails, or names). These set the default audience for new threads and enable `useDefaultRecipients`; each supplied list replaces that server-side list, while omitted lists are unchanged on update. Empty strings or empty list entries are errors. `--clear-default-audience` disables defaults and empties both lists, and cannot be combined with either list option. `--dry-run` prints resolved IDs before any mutation. This does not change channel membership. + +`channel list --json --full` preserves `useDefaultRecipients`, `defaultGroups`, and `defaultRecipients`. For a single channel, `channel members list --json` includes these fields and `defaultAudience: { useDefaultRecipients, groups: [{id, name}], users: [{id, name, email}] }` (also with `--full` or `--ndjson`). Unavailable names/emails are null. + For `tdc channel members add/remove/set`, refs accept user identifiers (`id:N`, email, name) or `group:`, which expands to the group's current members. Group expansion is one-shot — it is not a persistent link, so users added to the group later will not auto-join the channel. `set` replaces membership with the resolved set and is dry-run by default (pass `--apply` to mutate); it refuses to remove the acting user unless `--include-self` is passed. ## Reactions diff --git a/src/commands/channel/channel.test.ts b/src/commands/channel/channel.test.ts index 5a277ea..ac19fa1 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -3,6 +3,7 @@ import { createTestProgram, describeEmptyMachineOutput, } from '@doist/cli-core/testing' +import { CommsApi, type CustomFetch } from '@doist/comms-sdk' import { beforeEach, describe, expect, it, vi } from 'vitest' const apiMocks = vi.hoisted(() => ({ @@ -16,6 +17,7 @@ const refsMocks = vi.hoisted(() => ({ parseRef: vi.fn(), getDirectChannelId: vi.fn(), resolveUserRefs: vi.fn(), + resolveGroupRef: vi.fn(), })) const globalArgsMocks = vi.hoisted(() => ({ @@ -34,6 +36,7 @@ vi.mock('../../lib/refs.js', () => ({ parseRef: refsMocks.parseRef, getDirectChannelId: refsMocks.getDirectChannelId, resolveUserRefs: refsMocks.resolveUserRefs, + resolveGroupRef: refsMocks.resolveGroupRef, })) vi.mock('../../lib/global-args.js', () => ({ @@ -130,6 +133,22 @@ describe('channels list', () => { expect(consoleSpy.mock.calls[0][0]).not.toContain('Leadership') }) + it('preserves all default audience fields in full JSON', async () => { + const audience = { + useDefaultRecipients: true, + defaultGroups: ['GR1'], + defaultRecipients: [10], + } + apiMocks.getCommsClient.mockResolvedValue( + createClient({ + joinedChannels: [createChannel(10, 'General', audience)], + }), + ) + const log = captureConsole('log') + await runChannelCommand(['list', '--json', '--full']) + expect(JSON.parse(log.mock.calls[0][0])[0]).toMatchObject(audience) + }) + it('also works via the singular channel command name', async () => { const client = createClient({ joinedChannels: [createChannel(10, 'General')], @@ -902,3 +921,228 @@ describe('channels lifecycle direct refs', () => { }) } }) + +describe.each(['create', 'update'])('channel %s default audience', (command) => { + const channel = createChannel(10, 'Engineering', { + id: 'CH10', + workspaceId: 69, + useDefaultRecipients: true, + defaultGroups: ['OLD'], + defaultRecipients: [99], + }) + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.parseRef.mockImplementation((ref: string) => ({ type: 'name', name: ref })) + refsMocks.getDirectChannelId.mockReturnValue(command === 'update' ? 'CH10' : null) + refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 69, name: 'Doist' }) + refsMocks.resolveChannelRef.mockResolvedValue(channel) + refsMocks.resolveGroupRef.mockImplementation(async (ref: string) => ({ + id: ref === '26Q4|KLM' ? 'GR1' : 'GR2', + })) + refsMocks.resolveUserRefs.mockResolvedValue([10, 20, 30]) + }) + + function setup() { + const client = createClient({ createdChannel: channel, updatedChannel: channel }) + client.channels.getChannel.mockResolvedValue(channel) + apiMocks.getCommsClient.mockResolvedValue(client) + const log = captureConsole('log') + const args = + command === 'create' + ? ['create', 'Engineering', '--workspace', 'Doist'] + : ['update', 'id:CH10'] + const mutation = + command === 'create' ? client.channels.createChannel : client.channels.updateChannel + return { client, log, args, mutation } + } + + it.each([ + { + flags: ['--default-groups', '26Q4|KLM, id:GR2'], + fields: { useDefaultRecipients: true, defaultGroups: ['GR1', 'GR2'] }, + }, + { + flags: ['--default-users', 'id:10,alice@doist.com,Bob'], + fields: { useDefaultRecipients: true, defaultRecipients: [10, 20, 30] }, + }, + { + flags: ['--default-groups', '26Q4|KLM', '--default-users', 'id:10,alice@doist.com,Bob'], + fields: { + useDefaultRecipients: true, + defaultGroups: ['GR1'], + defaultRecipients: [10, 20, 30], + }, + }, + { + flags: ['--clear-default-audience'], + fields: { useDefaultRecipients: false, defaultGroups: [], defaultRecipients: [] }, + }, + ])('sends exactly the requested fields for $flags', async ({ flags, fields }) => { + const { args, mutation } = setup() + await runChannelCommand([...args, ...flags, '--json']) + expect(mutation).toHaveBeenCalledWith({ + ...(command === 'create' ? { workspaceId: 69 } : { id: 'CH10' }), + name: 'Engineering', + ...fields, + }) + if (flags.includes('--default-groups')) { + expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith('26Q4|KLM', 69) + if (flags[1].includes(',')) + expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith('id:GR2', 69) + } + if (flags.includes('--default-users')) { + expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('id:10,alice@doist.com,Bob', 69) + } + }) + + it.each([false, true])( + 'sends audience fields through the SDK transport (clear=%s)', + async (clear) => { + const { args } = setup() + const response = { + id: 'CeRAj1WU3YFhsTejuePLW', + name: 'Engineering', + workspace_id: 69, + public: true, + archived: false, + creator: 1, + created_ts: 1767225600, + version: 1, + } + const transport = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: {}, + text: async () => JSON.stringify(response), + json: async () => response, + }) + const sdk = new CommsApi('test-token', { customFetch: transport }) + apiMocks.getCommsClient.mockResolvedValue(sdk) + await runChannelCommand([ + ...args, + ...(clear + ? ['--clear-default-audience'] + : ['--default-groups', '26Q4|KLM', '--default-users', 'Alice']), + '--json', + ]) + const posts = transport.mock.calls.filter(([, init]) => init?.method === 'POST') + expect(posts).toHaveLength(1) + const body = JSON.parse(String(posts[0][1]?.body)) + expect(body).toMatchObject({ + use_default_recipients: !clear, + default_groups: clear ? [] : ['GR1'], + default_recipients: clear ? [] : [10, 20, 30], + }) + }, + ) + + it('prints resolved IDs in dry-run without mutating', async () => { + const { args, client, log } = setup() + await runChannelCommand([ + ...args, + '--default-groups', + '26Q4|KLM', + '--default-users', + 'Alice', + '--dry-run', + ]) + expect(client.channels.createChannel).not.toHaveBeenCalled() + expect(client.channels.updateChannel).not.toHaveBeenCalled() + const output = log.mock.calls.map((call) => call[0]).join('\n') + expect(output).toContain('useDefaultRecipients: true') + expect(output).toContain('defaultGroups: ["GR1"]') + expect(output).toContain('defaultRecipients: [10,20,30]') + }) + + it('previews clearing both lists without resolving references', async () => { + const { args, mutation, log } = setup() + await runChannelCommand([...args, '--clear-default-audience', '--dry-run']) + expect(mutation).not.toHaveBeenCalled() + expect(refsMocks.resolveGroupRef).not.toHaveBeenCalled() + expect(refsMocks.resolveUserRefs).not.toHaveBeenCalled() + const output = log.mock.calls.map((call) => call[0]).join('\n') + expect(output).toContain('useDefaultRecipients: false') + expect(output).toContain('defaultGroups: []') + expect(output).toContain('defaultRecipients: []') + }) + + it.each(['--default-groups', '--default-users'])( + 'rejects %s combined with clearing', + async (flag) => { + const { args, mutation } = setup() + await expect( + runChannelCommand([...args, flag, 'Alice', '--clear-default-audience']), + ).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS') + expect(mutation).not.toHaveBeenCalled() + expect(apiMocks.getCommsClient).not.toHaveBeenCalled() + expect(refsMocks.resolveGroupRef).not.toHaveBeenCalled() + expect(refsMocks.resolveUserRefs).not.toHaveBeenCalled() + }, + ) + + for (const flag of ['--default-groups', '--default-users']) { + it.each(['', ' ', ',', 'Alice,', ',Alice', 'Alice, ,Bob'])( + 'rejects empty list entries in ' + flag + ' %j', + async (refs) => { + const { args, mutation } = setup() + await expect(runChannelCommand([...args, flag, refs])).rejects.toHaveProperty( + 'code', + 'INVALID_VALUE', + ) + expect(mutation).not.toHaveBeenCalled() + expect(refsMocks.resolveGroupRef).not.toHaveBeenCalled() + expect(refsMocks.resolveUserRefs).not.toHaveBeenCalled() + }, + ) + } + + it.each(['--default-groups', '--default-users'])( + 'does not mutate when %s fails to resolve', + async (flag) => { + const { args, mutation } = setup() + const error = new Error('No match') + if (flag === '--default-groups') refsMocks.resolveGroupRef.mockRejectedValueOnce(error) + else refsMocks.resolveUserRefs.mockRejectedValueOnce(error) + await expect(runChannelCommand([...args, flag, 'Typo'])).rejects.toThrow(error) + expect(mutation).not.toHaveBeenCalled() + }, + ) + + if (command === 'update') { + it('resolves audience in the selected workspace for a channel name', async () => { + const { mutation } = setup() + await runChannelCommand([ + 'update', + 'Engineering', + '--workspace', + 'Doist', + '--default-groups', + '26Q4|KLM', + ]) + expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 69) + expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith('26Q4|KLM', 69) + expect(mutation).toHaveBeenCalledWith({ + id: 'CH10', + name: 'Engineering', + useDefaultRecipients: true, + defaultGroups: ['GR1'], + }) + }) + + it('fetches the channel workspace even when also renaming a direct ref', async () => { + const { args, client, mutation } = setup() + await runChannelCommand([...args, '--name', 'Renamed', '--default-users', 'Alice']) + expect(client.channels.getChannel).toHaveBeenCalledWith('CH10') + expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('Alice', 69) + expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled() + expect(mutation).toHaveBeenCalledWith({ + id: 'CH10', + name: 'Renamed', + useDefaultRecipients: true, + defaultRecipients: [10, 20, 30], + }) + }) + } +}) diff --git a/src/commands/channel/create.ts b/src/commands/channel/create.ts index 6158cb2..3f2ddc1 100644 --- a/src/commands/channel/create.ts +++ b/src/commands/channel/create.ts @@ -3,28 +3,43 @@ import { getCommsClient } from '../../lib/api.js' import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' import { resolveUserRefs } from '../../lib/refs.js' +import { + type DefaultAudienceOptions, + buildDefaultAudienceArgs, + clearDefaultAudienceArgs, + defaultAudienceDryRun, + validateDefaultAudienceOptions, +} from './default-audience.js' import { resolveChannelWorkspaceId, resolveVisibilityOption, validateChannelName, } from './helpers.js' -type CreateChannelOptions = MutationOptions & { - workspace?: string - description?: string - users?: string - public?: boolean - private?: boolean -} +type CreateChannelOptions = MutationOptions & + DefaultAudienceOptions & { + workspace?: string + description?: string + users?: string + public?: boolean + private?: boolean + } export async function createChannel(name: string, options: CreateChannelOptions): Promise { + validateDefaultAudienceOptions(options) validateChannelName(name) const visibility = resolveVisibilityOption(options) const workspaceId = await resolveChannelWorkspaceId(options.workspace) const userIds = options.users ? await resolveUserRefs(options.users, workspaceId) : undefined + const defaultAudience = { + ...clearDefaultAudienceArgs(options), + ...(await buildDefaultAudienceArgs(options, workspaceId)), + } + const args: CreateChannelArgs = { + ...defaultAudience, workspaceId, name, ...(options.description !== undefined ? { description: options.description } : {}), @@ -34,6 +49,7 @@ export async function createChannel(name: string, options: CreateChannelOptions) if (options.dryRun) { printDryRun('create channel', { + ...defaultAudienceDryRun(defaultAudience), Workspace: String(workspaceId), Name: name, Description: options.description, diff --git a/src/commands/channel/default-audience.ts b/src/commands/channel/default-audience.ts new file mode 100644 index 0000000..2d635cc --- /dev/null +++ b/src/commands/channel/default-audience.ts @@ -0,0 +1,74 @@ +import type { UpdateChannelArgs } from '@doist/comms-sdk' +import { CliError } from '../../lib/errors.js' +import { resolveGroupRef, resolveUserRefs } from '../../lib/refs.js' + +export type DefaultAudienceOptions = { + defaultGroups?: string + defaultUsers?: string + clearDefaultAudience?: boolean +} + +// SDK 3.3 forwards this boolean but omits it from its channel request types. +export type DefaultAudienceArgs = Pick & { + useDefaultRecipients?: boolean +} + +export function validateDefaultAudienceOptions(options: DefaultAudienceOptions): void { + if ( + options.clearDefaultAudience && + (options.defaultGroups !== undefined || options.defaultUsers !== undefined) + ) { + throw new CliError( + 'CONFLICTING_OPTIONS', + 'Cannot combine --clear-default-audience with --default-groups or --default-users.', + ) + } + + for (const [flag, refs] of [ + ['--default-groups', options.defaultGroups], + ['--default-users', options.defaultUsers], + ]) { + if (refs !== undefined && refs.split(',').some((ref) => !ref.trim())) { + throw new CliError('INVALID_VALUE', `${flag} requires non-empty references.`, [ + 'Use --clear-default-audience to clear the default audience.', + ]) + } + } +} + +export async function buildDefaultAudienceArgs( + options: DefaultAudienceOptions, + workspaceId: number, +): Promise { + const args: DefaultAudienceArgs = {} + if (options.defaultGroups !== undefined) { + const groups = await Promise.all( + options.defaultGroups.split(',').map((ref) => resolveGroupRef(ref.trim(), workspaceId)), + ) + args.defaultGroups = groups.map((group) => group.id) + args.useDefaultRecipients = true + } + if (options.defaultUsers !== undefined) { + args.defaultRecipients = await resolveUserRefs(options.defaultUsers, workspaceId) + args.useDefaultRecipients = true + } + return args +} + +export function clearDefaultAudienceArgs(options: DefaultAudienceOptions): DefaultAudienceArgs { + return options.clearDefaultAudience + ? { useDefaultRecipients: false, defaultGroups: [], defaultRecipients: [] } + : {} +} + +export function defaultAudienceDryRun( + args: DefaultAudienceArgs, +): Record { + return { + useDefaultRecipients: args.useDefaultRecipients?.toString(), + defaultGroups: args.defaultGroups ? JSON.stringify(args.defaultGroups) : undefined, + defaultRecipients: args.defaultRecipients + ? JSON.stringify(args.defaultRecipients) + : undefined, + } +} diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index 602501f..387387e 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -66,6 +66,18 @@ Notes: .option('--users ', 'Comma-separated user references to add (id:N, email, or name)') .option('--public', 'Create a public channel') .option('--private', 'Create a private channel') + .option( + '--default-groups ', + 'Replace default audience groups (comma-separated IDs or names)', + ) + .option( + '--default-users ', + 'Replace default audience users (comma-separated id:N, email, or name)', + ) + .option( + '--clear-default-audience', + 'Disable the default audience and clear its groups and users', + ) .option('--dry-run', 'Show what would be created without creating') .option('--json', 'Output created channel as JSON') .option('--full', 'Include all fields in JSON output') @@ -74,20 +86,37 @@ Notes: ` Examples: tdc channel create "Engineering" + tdc channel create "Squad" --default-groups "Design" --default-users alice@doist.com tdc channel create "Leadership Team" --private --users id:10,id:20 - tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json`, + tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json + +Notes: + Default audience lists must be non-empty and enable default recipients. + --clear-default-audience cannot be combined with either list option.`, ) .action(createChannel) channel .command('update [name]') - .description('Update channel metadata') + .description('Update channel metadata and default audience') .option('--workspace ', 'Workspace ID or name') .option('--name ', 'New channel name') .option('--description ', 'New channel description') .option('--clear-description', 'Clear the channel description') .option('--public', 'Make the channel public') .option('--private', 'Make the channel private') + .option( + '--default-groups ', + 'Replace default audience groups (comma-separated IDs or names)', + ) + .option( + '--default-users ', + 'Replace default audience users (comma-separated id:N, email, or name)', + ) + .option( + '--clear-default-audience', + 'Disable the default audience and clear its groups and users', + ) .option('--dry-run', 'Show what would be updated without updating') .option('--json', 'Output updated channel as JSON') .option('--full', 'Include all fields in JSON output') @@ -97,7 +126,14 @@ Examples: Examples: tdc channel update "Engineering" "Platform Engineering" tdc channel update id:abc123 --description "Team discussions" - tdc channel update "Leadership" --private --json`, + tdc channel update id:abc123 --default-groups "Design" --dry-run + tdc channel update id:abc123 --clear-default-audience + tdc channel update "Leadership" --private --json + +Notes: + Each default audience list replaces that list; omitted lists are unchanged. + Lists must be non-empty and enable default recipients. + Use --clear-default-audience alone to disable defaults and empty both lists.`, ) .action(updateChannel) @@ -220,7 +256,8 @@ Examples: Notes: "Groups fully in channel" lists groups whose entire current membership is - already in the channel — a hint, not a persistent link.`, + already in the channel — a hint, not a persistent link. + JSON includes defaultAudience with resolved group and user names.`, ) .action(listChannelMembers) diff --git a/src/commands/channel/members.test.ts b/src/commands/channel/members.test.ts index 062c2fb..92bc66c 100644 --- a/src/commands/channel/members.test.ts +++ b/src/commands/channel/members.test.ts @@ -126,8 +126,68 @@ describe('tdc channel members list (default)', () => { ]) }) + it.each([['--json'], ['--json', '--full'], ['--ndjson']])( + 'exposes default audience with %j', + async (...flags) => { + const audience = { + useDefaultRecipients: true, + defaultGroups: ['GR200', 'missing'], + defaultRecipients: [1, 4, 99], + } + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2, 3], audience)) + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const log = captureConsole('log') + await createProgram().parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'list', + 'General', + ...flags, + ]) + const payload = JSON.parse(log.mock.calls[0][0] as string) + expect(payload).toMatchObject(audience) + expect(payload.defaultAudience).toEqual({ + useDefaultRecipients: true, + groups: [ + { id: 'GR200', name: 'Backend' }, + { id: 'missing', name: null }, + ], + users: [ + { id: 1, name: 'Alice', email: 'a@d.com' }, + { id: 4, name: 'Dave', email: 'd@d.com' }, + { id: 99, name: null, email: null }, + ], + }) + expect(payload.members).toHaveLength(3) + expect(client.workspaceUsers.getUserById).toHaveBeenCalledTimes(5) + expect(client.channels.getChannel).not.toHaveBeenCalled() + }, + ) + + it('exposes an empty disabled default audience', async () => { + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel([], { + useDefaultRecipients: false, + defaultGroups: [], + defaultRecipients: [], + }), + ) + const log = captureConsole('log') + await createProgram().parseAsync(['node', 'tdc', 'channel', 'members', 'General', '--json']) + expect(JSON.parse(log.mock.calls[0][0] as string).defaultAudience).toEqual({ + useDefaultRecipients: false, + groups: [], + users: [], + }) + }) + it('omits email for restricted members', async () => { - refsMocks.resolveChannelRef.mockResolvedValue(createChannel([6])) + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel([6], { useDefaultRecipients: true, defaultRecipients: [6] }), + ) apiMocks.getCommsClient.mockResolvedValue({ workspaceUsers: { getUserById: vi.fn().mockResolvedValue({ @@ -145,6 +205,11 @@ describe('tdc channel members list (default)', () => { const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(payload.members[0]).toEqual({ id: 6, name: 'Restricted User', email: null }) + expect(payload.defaultAudience.users[0]).toEqual({ + id: 6, + name: 'Restricted User', + email: null, + }) }) it('falls back to user: for unknown members', async () => { diff --git a/src/commands/channel/members.ts b/src/commands/channel/members.ts index cd60b85..10d83d9 100644 --- a/src/commands/channel/members.ts +++ b/src/commands/channel/members.ts @@ -23,29 +23,51 @@ export async function listChannelMembers( const [channel, groups] = await Promise.all([channelPromise, getWorkspaceGroups(workspaceId)]) const userIds = channelUserIds(channel) - const userMap = await fetchUsersByIds(workspaceId, userIds) + const includeAudience = outputMode === 'json' || outputMode === 'ndjson' + const defaultRecipients = channel.defaultRecipients ?? [] + const defaultGroups = channel.defaultGroups ?? [] + const userMap = await fetchUsersByIds( + workspaceId, + includeAudience ? [...new Set([...userIds, ...defaultRecipients])] : userIds, + ) const userIdSet = new Set(userIds) const fullyInChannel = groupsFullyInChannel(groups, userIdSet) - const members = userIds.map((id) => { + const describeUser = (id: number) => { const user = userMap.get(id) const email = user && !isRestrictedWorkspaceUser(user) ? (user.email ?? null) : null return { id, name: user?.fullName ?? null, email } - }) + } + const members = userIds.map(describeUser) + const groupMap = new Map(groups.map((group) => [group.id, group])) + const defaultAudience = { + useDefaultRecipients: channel.useDefaultRecipients ?? false, + groups: defaultGroups.map((id) => ({ id, name: groupMap.get(id)?.name ?? null })), + users: defaultRecipients.map(describeUser), + } const slimPayload = { id: channel.id, name: channel.name, workspaceId: channel.workspaceId, members, + useDefaultRecipients: channel.useDefaultRecipients ?? false, + defaultGroups, + defaultRecipients, + defaultAudience, groupsFullyInChannel: fullyInChannel.map((g) => ({ id: g.id, name: g.name, userIds: g.userIds, })), } - const fullPayload = { ...channel, members, groupsFullyInChannel: fullyInChannel } + const fullPayload = { + ...channel, + members, + defaultAudience, + groupsFullyInChannel: fullyInChannel, + } if (outputMode === 'json') { console.log(formatJson(options.full ? fullPayload : slimPayload)) diff --git a/src/commands/channel/update.ts b/src/commands/channel/update.ts index 5bd2e12..efdfdb3 100644 --- a/src/commands/channel/update.ts +++ b/src/commands/channel/update.ts @@ -4,20 +4,29 @@ import { CliError } from '../../lib/errors.js' import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' import { getDirectChannelId, resolveChannelRef } from '../../lib/refs.js' +import { + type DefaultAudienceOptions, + type DefaultAudienceArgs, + buildDefaultAudienceArgs, + clearDefaultAudienceArgs, + defaultAudienceDryRun, + validateDefaultAudienceOptions, +} from './default-audience.js' import { resolveChannelWorkspaceId, resolveVisibilityOption, validateChannelName, } from './helpers.js' -type UpdateChannelOptions = MutationOptions & { - workspace?: string - name?: string - description?: string - clearDescription?: boolean - public?: boolean - private?: boolean -} +type UpdateChannelOptions = MutationOptions & + DefaultAudienceOptions & { + workspace?: string + name?: string + description?: string + clearDescription?: boolean + public?: boolean + private?: boolean + } function buildDescriptionUpdate(options: UpdateChannelOptions): string | null | undefined { if (options.description !== undefined && options.clearDescription) { @@ -36,9 +45,11 @@ function printUpdateDryRun( newName: string | undefined, description: string | null | undefined, visibility: boolean | undefined, + defaultAudience: DefaultAudienceArgs, ): void { printDryRun('update channel', { Channel: targetLabel, + ...defaultAudienceDryRun(defaultAudience), 'New name': newName, Description: description === null ? '(clear)' : description !== undefined ? description : undefined, @@ -51,6 +62,11 @@ export async function updateChannel( positionalName: string | undefined, options: UpdateChannelOptions, ): Promise { + validateDefaultAudienceOptions(options) + const hasAudienceRefs = + options.defaultGroups !== undefined || options.defaultUsers !== undefined + let defaultAudience = clearDefaultAudienceArgs(options) + if (positionalName && options.name) { throw new CliError( 'CONFLICTING_OPTIONS', @@ -66,9 +82,15 @@ export async function updateChannel( const description = buildDescriptionUpdate(options) const visibility = resolveVisibilityOption(options) - if (newName === undefined && description === undefined && visibility === undefined) { + if ( + newName === undefined && + description === undefined && + visibility === undefined && + !hasAudienceRefs && + !options.clearDefaultAudience + ) { throw new CliError('INVALID_VALUE', 'Provide at least one channel field to update.', [ - 'Use a new name, --name, --description, --clear-description, --public, or --private.', + 'Use a new name, --name, --description, --clear-description, --public, --private, --default-groups, --default-users, or --clear-default-audience.', ]) } @@ -80,15 +102,24 @@ export async function updateChannel( const directChannelId = options.workspace ? null : getDirectChannelId(channelRef) if (directChannelId) { channelId = directChannelId - if (newName === undefined && options.dryRun) { - printUpdateDryRun(`id:${directChannelId}`, newName, description, visibility) + if (newName === undefined && options.dryRun && !hasAudienceRefs) { + printUpdateDryRun( + `id:${directChannelId}`, + newName, + description, + visibility, + defaultAudience, + ) return } - if (newName === undefined) { + if (newName === undefined || hasAudienceRefs) { client = await getCommsClient() const channel = await client.channels.getChannel(directChannelId) - updateName = channel.name + updateName = newName ?? channel.name + if (hasAudienceRefs) { + defaultAudience = await buildDefaultAudienceArgs(options, channel.workspaceId) + } targetLabel = `${channel.name} (id:${channel.id})` } else { updateName = newName @@ -97,17 +128,21 @@ export async function updateChannel( } else { const workspaceId = await resolveChannelWorkspaceId(options.workspace) const channel = await resolveChannelRef(channelRef, workspaceId) + if (hasAudienceRefs) { + defaultAudience = await buildDefaultAudienceArgs(options, channel.workspaceId) + } channelId = channel.id updateName = newName ?? channel.name targetLabel = `${channel.name} (id:${channel.id})` } if (options.dryRun) { - printUpdateDryRun(targetLabel, newName, description, visibility) + printUpdateDryRun(targetLabel, newName, description, visibility, defaultAudience) return } const args: UpdateChannelArgs = { + ...defaultAudience, id: channelId, name: updateName, ...(description !== undefined ? { description } : {}), diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index e08d0d3..e11d4d8 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -264,6 +264,10 @@ tdc channels --ids-only # Output only channel IDs tdc channel create "Engineering" # Create a channel in the current workspace tdc channel create "Leadership Team" --private --users id:10,id:20 # Create private channel with initial members tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json # Create and return channel as JSON +tdc channel create "Squad" --default-groups "Design" --default-users alice@doist.com # Set default thread audience +tdc channel update --default-groups "Design,Backend" --dry-run # Preview resolved group IDs +tdc channel update --default-users id:10,alice@doist.com # Replace default user recipients +tdc channel update --clear-default-audience # Disable defaults and empty both lists tdc channel update "New name" # Rename a channel tdc channel update --name "New name" # Rename with an explicit flag tdc channel update --description "Team discussions" # Update channel description @@ -283,7 +287,7 @@ tdc channel threads --limit 20 --cursor # Paginate tdc channel threads --json # { results, nextCursor } with isUnread + url tdc channel threads --ids-only # Output only thread IDs; pagination notice goes to stderr tdc channel members # List a channel's members + groups fully in the channel -tdc channel members --json # JSON with id, name, workspaceId, members +tdc channel members --json # JSON with members, groupsFullyInChannel, and defaultAudience tdc channel members --ids-only # Output only member user IDs tdc channel members add alice group:Design # Add users and/or expand group: members tdc channel members add a@d.com id:789 --json # Add refs, output result as JSON @@ -318,6 +322,10 @@ If a channel is not found in \`tdc channels\`, widen with broader listings such \`tdc channel threads\` returns every thread in the channel; pagination filters (\`--limit\`, \`--cursor\`, \`--since\`, \`--until\`, \`--unread\`) are applied client-side after fetch. \`--archive-filter\` is applied server-side. Results are sorted newest-first by last activity. In \`--json\` / \`--ndjson\`, the response includes a \`nextCursor\` string (opaque) you can pass via \`--cursor\` to fetch the next page; NDJSON emits the cursor as a final \`{ "_meta": true, "nextCursor": "..." }\` line. +Both \`channel create\` and \`channel update\` accept \`--default-groups \` (comma-separated group IDs or names) and \`--default-users \` (comma-separated user IDs, emails, or names). These set the default audience for new threads and enable \`useDefaultRecipients\`; each supplied list replaces that server-side list, while omitted lists are unchanged on update. Empty strings or empty list entries are errors. \`--clear-default-audience\` disables defaults and empties both lists, and cannot be combined with either list option. \`--dry-run\` prints resolved IDs before any mutation. This does not change channel membership. + +\`channel list --json --full\` preserves \`useDefaultRecipients\`, \`defaultGroups\`, and \`defaultRecipients\`. For a single channel, \`channel members list --json\` includes these fields and \`defaultAudience: { useDefaultRecipients, groups: [{id, name}], users: [{id, name, email}] }\` (also with \`--full\` or \`--ndjson\`). Unavailable names/emails are null. + For \`tdc channel members add/remove/set\`, refs accept user identifiers (\`id:N\`, email, name) or \`group:\`, which expands to the group's current members. Group expansion is one-shot — it is not a persistent link, so users added to the group later will not auto-join the channel. \`set\` replaces membership with the resolved set and is dry-run by default (pass \`--apply\` to mutate); it refuses to remove the acting user unless \`--include-self\` is passed. ## Reactions From ee53f889a4871f8076bf88f4a2ee66e78466c688 Mon Sep 17 00:00:00 2001 From: Jacobo de Vera Date: Fri, 23 Oct 2026 17:06:27 +0100 Subject: [PATCH 2/6] chore(deps): adopt SDK default audience request types --- package-lock.json | 8 ++++---- package.json | 2 +- src/commands/channel/default-audience.ts | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4dc568..2d57cf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@doist/cli-core": "1.6.0", - "@doist/comms-sdk": "3.8.1", + "@doist/comms-sdk": "3.9.0", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", @@ -184,9 +184,9 @@ } }, "node_modules/@doist/comms-sdk": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-3.8.1.tgz", - "integrity": "sha512-6lgdPNDd1u70pwCdCGcW5T2TBJHPkJ5fZubzwKjceCmL0j/OIhvmriOuLa+rFt2YuEmcGleIhJIMB2NBYFXj7Q==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-3.9.0.tgz", + "integrity": "sha512-Sy1p+T2hpwRUXIHSabgCsAUZkhWhRbW9IjleFlimz9X8DB4wXXvwdUxBHc0pnGUYRHwVm2d/NgFOa+5pgrTjxA==", "license": "MIT", "dependencies": { "@doist/sdk-kmp": "0.2.3", diff --git a/package.json b/package.json index cd672f2..4b4cf29 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ ], "dependencies": { "@doist/cli-core": "1.6.0", - "@doist/comms-sdk": "3.8.1", + "@doist/comms-sdk": "3.9.0", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", diff --git a/src/commands/channel/default-audience.ts b/src/commands/channel/default-audience.ts index e7624c2..6502063 100644 --- a/src/commands/channel/default-audience.ts +++ b/src/commands/channel/default-audience.ts @@ -8,10 +8,10 @@ export type DefaultAudienceOptions = { clearDefaultAudience?: boolean } -// The SDK forwards this boolean but omits it from its channel request types. -export type DefaultAudienceArgs = Pick & { - useDefaultRecipients?: boolean -} +export type DefaultAudienceArgs = Pick< + UpdateChannelArgs, + 'useDefaultRecipients' | 'defaultGroups' | 'defaultRecipients' +> export function validateDefaultAudienceOptions(options: DefaultAudienceOptions): void { if ( From 2b12123b30f1191f5d556d8347877548898128b7 Mon Sep 17 00:00:00 2001 From: Jacobo de Vera Date: Fri, 23 Oct 2026 17:15:38 +0100 Subject: [PATCH 3/6] docs: simplify channel default audience skill guidance --- skills/comms-cli/SKILL.md | 6 ++++-- src/lib/skills/content.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 08fc0c5..6082fb8 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -332,9 +332,11 @@ If a channel is not found in `tdc channels`, widen with broader listings such as `tdc channel threads` returns every thread in the channel; pagination filters (`--limit`, `--cursor`, `--since`, `--until`, `--unread`) are applied client-side after fetch. `--archive-filter` is applied server-side. Results are sorted newest-first by last activity. In `--json` / `--ndjson`, the response includes a `nextCursor` string (opaque) you can pass via `--cursor` to fetch the next page; NDJSON emits the cursor as a final `{ "_meta": true, "nextCursor": "..." }` line. -Both `channel create` and `channel update` accept `--default-groups ` (comma-separated group IDs or names) and `--default-users ` (comma-separated user IDs, emails, or names). These set the default audience for new threads and enable `useDefaultRecipients`; each supplied list replaces that server-side list, while omitted lists are unchanged on update. Empty strings or empty list entries are errors. `--clear-default-audience` disables defaults and empties both lists, and cannot be combined with either list option. `--dry-run` prints resolved IDs before any mutation. This does not change channel membership. +On `channel create` and `channel update`, `--default-groups` and `--default-users` set who new threads are addressed to. They do not add channel members. -`channel list --json --full` preserves `useDefaultRecipients`, `defaultGroups`, and `defaultRecipients`. For a single channel, `channel members list --json` includes these fields and `defaultAudience: { useDefaultRecipients, groups: [{id, name}], users: [{id, name, email}] }` (also with `--full` or `--ndjson`). Unavailable names/emails are null. +On update, each supplied list replaces that list. Omitted lists stay unchanged. Use `--clear-default-audience` to disable defaults and empty both lists. + +Use `channel members list --json` to inspect the default audience. For `tdc channel members add/remove/set`, refs accept user identifiers (`id:N`, email, name) or `group:`, which expands to the group's current members. Group expansion is one-shot — it is not a persistent link, so users added to the group later will not auto-join the channel. `set` replaces membership with the resolved set and is dry-run by default (pass `--apply` to mutate); it refuses to remove the acting user unless `--include-self` is passed. diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 98fc503..d45c523 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -336,9 +336,11 @@ If a channel is not found in \`tdc channels\`, widen with broader listings such \`tdc channel threads\` returns every thread in the channel; pagination filters (\`--limit\`, \`--cursor\`, \`--since\`, \`--until\`, \`--unread\`) are applied client-side after fetch. \`--archive-filter\` is applied server-side. Results are sorted newest-first by last activity. In \`--json\` / \`--ndjson\`, the response includes a \`nextCursor\` string (opaque) you can pass via \`--cursor\` to fetch the next page; NDJSON emits the cursor as a final \`{ "_meta": true, "nextCursor": "..." }\` line. -Both \`channel create\` and \`channel update\` accept \`--default-groups \` (comma-separated group IDs or names) and \`--default-users \` (comma-separated user IDs, emails, or names). These set the default audience for new threads and enable \`useDefaultRecipients\`; each supplied list replaces that server-side list, while omitted lists are unchanged on update. Empty strings or empty list entries are errors. \`--clear-default-audience\` disables defaults and empties both lists, and cannot be combined with either list option. \`--dry-run\` prints resolved IDs before any mutation. This does not change channel membership. +On \`channel create\` and \`channel update\`, \`--default-groups\` and \`--default-users\` set who new threads are addressed to. They do not add channel members. -\`channel list --json --full\` preserves \`useDefaultRecipients\`, \`defaultGroups\`, and \`defaultRecipients\`. For a single channel, \`channel members list --json\` includes these fields and \`defaultAudience: { useDefaultRecipients, groups: [{id, name}], users: [{id, name, email}] }\` (also with \`--full\` or \`--ndjson\`). Unavailable names/emails are null. +On update, each supplied list replaces that list. Omitted lists stay unchanged. Use \`--clear-default-audience\` to disable defaults and empty both lists. + +Use \`channel members list --json\` to inspect the default audience. For \`tdc channel members add/remove/set\`, refs accept user identifiers (\`id:N\`, email, name) or \`group:\`, which expands to the group's current members. Group expansion is one-shot — it is not a persistent link, so users added to the group later will not auto-join the channel. \`set\` replaces membership with the resolved set and is dry-run by default (pass \`--apply\` to mutate); it refuses to remove the acting user unless \`--include-self\` is passed. From ce7a9282e8b2dd7d1aa352483964f1e39905ed86 Mon Sep 17 00:00:00 2001 From: Jacobo de Vera Date: Fri, 23 Oct 2026 17:18:26 +0100 Subject: [PATCH 4/6] docs: leave changelog generation to releases --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a7946..0e78b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,3 @@ -## Unreleased - -### Features - -- **channel:** add default audience groups, users, and clearing to create/update, with resolved dry-run previews and default audience details in members JSON. - ## [3.7.0](https://github.com/Doist/comms-cli/compare/v3.6.6...v3.7.0) (2026-10-21) ### Features From 16bf097da7a97bb8c6c43367f876322f1884801c Mon Sep 17 00:00:00 2001 From: Jacobo de Vera Date: Fri, 23 Oct 2026 18:49:03 +0100 Subject: [PATCH 5/6] perf(channel): share group list during audience resolution --- src/commands/channel/channel.test.ts | 20 +++++- src/commands/channel/default-audience.test.ts | 71 +++++++++++++++++++ src/commands/channel/default-audience.ts | 9 ++- src/lib/refs.ts | 8 ++- 4 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 src/commands/channel/default-audience.test.ts diff --git a/src/commands/channel/channel.test.ts b/src/commands/channel/channel.test.ts index ac19fa1..83f5f6e 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const apiMocks = vi.hoisted(() => ({ getCommsClient: vi.fn(), getCurrentWorkspaceId: vi.fn().mockResolvedValue(1), + getWorkspaceGroups: vi.fn().mockResolvedValue([]), })) const refsMocks = vi.hoisted(() => ({ @@ -28,6 +29,7 @@ const globalArgsMocks = vi.hoisted(() => ({ vi.mock('../../lib/api.js', () => ({ getCommsClient: apiMocks.getCommsClient, getCurrentWorkspaceId: apiMocks.getCurrentWorkspaceId, + getWorkspaceGroups: apiMocks.getWorkspaceGroups, })) vi.mock('../../lib/refs.js', () => ({ @@ -987,9 +989,17 @@ describe.each(['create', 'update'])('channel %s default audience', (command) => ...fields, }) if (flags.includes('--default-groups')) { - expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith('26Q4|KLM', 69) + expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith( + '26Q4|KLM', + 69, + expect.any(Promise), + ) if (flags[1].includes(',')) - expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith('id:GR2', 69) + expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith( + 'id:GR2', + 69, + expect.any(Promise), + ) } if (flags.includes('--default-users')) { expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('id:10,alice@doist.com,Bob', 69) @@ -1122,7 +1132,11 @@ describe.each(['create', 'update'])('channel %s default audience', (command) => '26Q4|KLM', ]) expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 69) - expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith('26Q4|KLM', 69) + expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith( + '26Q4|KLM', + 69, + expect.any(Promise), + ) expect(mutation).toHaveBeenCalledWith({ id: 'CH10', name: 'Engineering', diff --git a/src/commands/channel/default-audience.test.ts b/src/commands/channel/default-audience.test.ts new file mode 100644 index 0000000..5934b19 --- /dev/null +++ b/src/commands/channel/default-audience.test.ts @@ -0,0 +1,71 @@ +import type { Group } from '@doist/comms-sdk' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const apiMocks = vi.hoisted(() => ({ + getWorkspaceGroups: vi.fn(), + getGroup: vi.fn(), + getWorkspaceUsers: vi.fn(), +})) + +vi.mock('../../lib/api.js', () => apiMocks) + +import { buildDefaultAudienceArgs } from './default-audience.js' + +const groups: Group[] = [ + { id: 'GR1', name: 'Design', workspaceId: 69, userIds: [], version: 1 }, + { id: 'GR2', name: 'Backend', workspaceId: 69, userIds: [], version: 1 }, +] + +beforeEach(() => { + vi.resetAllMocks() + apiMocks.getWorkspaceGroups.mockResolvedValue(groups) + apiMocks.getGroup.mockResolvedValue(groups[1]) +}) + +describe('default audience group resolution', () => { + it('shares one workspace list for multiple names and preserves input order', async () => { + await expect( + buildDefaultAudienceArgs({ defaultGroups: ' Back, design ' }, 69), + ).resolves.toEqual({ useDefaultRecipients: true, defaultGroups: ['GR2', 'GR1'] }) + expect(apiMocks.getWorkspaceGroups.mock.calls).toEqual([[69]]) + expect(apiMocks.getGroup).not.toHaveBeenCalled() + }) + + it('does not fetch the workspace list for ID-only input', async () => { + await expect(buildDefaultAudienceArgs({ defaultGroups: 'id:GR2' }, 69)).resolves.toEqual({ + useDefaultRecipients: true, + defaultGroups: ['GR2'], + }) + expect(apiMocks.getWorkspaceGroups).not.toHaveBeenCalled() + expect(apiMocks.getGroup).toHaveBeenCalledWith('GR2', 69) + }) + + it('starts ID lookups without waiting for the shared name lookup', async () => { + const pendingGroups = Promise.withResolvers() + apiMocks.getWorkspaceGroups.mockReturnValue(pendingGroups.promise) + const result = buildDefaultAudienceArgs({ defaultGroups: 'Design,id:GR2,Back' }, 69) + + expect(apiMocks.getGroup).toHaveBeenCalledWith('GR2', 69) + pendingGroups.resolve(groups) + + await expect(result).resolves.toEqual({ + useDefaultRecipients: true, + defaultGroups: ['GR1', 'GR2', 'GR2'], + }) + expect(apiMocks.getWorkspaceGroups.mock.calls).toEqual([[69]]) + }) + + it.each([ + { ref: 'Design', code: 'AMBIGUOUS_GROUP' }, + { ref: 'Marketing', code: 'GROUP_NOT_FOUND' }, + ])('preserves $code errors with a shared list', async ({ ref, code }) => { + apiMocks.getWorkspaceGroups.mockResolvedValue([ + { ...groups[0], name: 'Design East' }, + { ...groups[1], name: 'Design West' }, + ]) + await expect(buildDefaultAudienceArgs({ defaultGroups: ref }, 69)).rejects.toHaveProperty( + 'code', + code, + ) + }) +}) diff --git a/src/commands/channel/default-audience.ts b/src/commands/channel/default-audience.ts index 6502063..23ed8fa 100644 --- a/src/commands/channel/default-audience.ts +++ b/src/commands/channel/default-audience.ts @@ -1,6 +1,7 @@ import type { UpdateChannelArgs } from '@doist/comms-sdk' +import { getWorkspaceGroups } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' -import { resolveGroupRef, resolveUserRefs } from '../../lib/refs.js' +import { parseRef, resolveGroupRef, resolveUserRefs } from '../../lib/refs.js' export type DefaultAudienceOptions = { defaultGroups?: string @@ -42,8 +43,12 @@ export async function buildDefaultAudienceArgs( ): Promise { const args: DefaultAudienceArgs = {} if (options.defaultGroups !== undefined) { + const refs = options.defaultGroups.split(',').map((ref) => ref.trim()) + const workspaceGroups = refs.some((ref) => parseRef(ref).type === 'name') + ? getWorkspaceGroups(workspaceId) + : undefined const groups = await Promise.all( - options.defaultGroups.split(',').map((ref) => resolveGroupRef(ref.trim(), workspaceId)), + refs.map((ref) => resolveGroupRef(ref, workspaceId, workspaceGroups)), ) args.defaultGroups = groups.map((group) => group.id) args.useDefaultRecipients = true diff --git a/src/lib/refs.ts b/src/lib/refs.ts index 921ddc4..9b2e219 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -500,7 +500,11 @@ export function parseNotifyIdRefs(refs: string): string[] { }) } -export async function resolveGroupRef(ref: string, workspaceId: number): Promise { +export async function resolveGroupRef( + ref: string, + workspaceId: number, + workspaceGroups?: Promise, +): Promise { const parsed = parseRef(ref) if (parsed.type === 'id') { @@ -524,7 +528,7 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise } if (parsed.type === 'name') { - const groups = await getWorkspaceGroups(workspaceId) + const groups = await (workspaceGroups ?? getWorkspaceGroups(workspaceId)) return matchByName(groups, parsed.name, { ambiguousCode: 'AMBIGUOUS_GROUP', notFoundCode: 'GROUP_NOT_FOUND', From b551f66718a43e4681cb5709de0874af3c4cb627 Mon Sep 17 00:00:00 2001 From: Jacobo de Vera Date: Fri, 23 Oct 2026 18:49:57 +0100 Subject: [PATCH 6/6] perf(channel): resolve default groups and users concurrently --- src/commands/channel/default-audience.test.ts | 32 +++++++++++++++++++ src/commands/channel/default-audience.ts | 19 ++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/commands/channel/default-audience.test.ts b/src/commands/channel/default-audience.test.ts index 5934b19..998af4d 100644 --- a/src/commands/channel/default-audience.test.ts +++ b/src/commands/channel/default-audience.test.ts @@ -69,3 +69,35 @@ describe('default audience group resolution', () => { ) }) }) + +describe('default audience concurrent resolution', () => { + it('starts user resolution while groups are pending and waits for both results', async () => { + const pendingGroups = Promise.withResolvers() + const pendingUsers = + Promise.withResolvers<{ id: number; fullName: string; email: string }[]>() + apiMocks.getWorkspaceGroups.mockReturnValue(pendingGroups.promise) + apiMocks.getWorkspaceUsers.mockReturnValue(pendingUsers.promise) + + const resolved = vi.fn() + const result = buildDefaultAudienceArgs( + { defaultGroups: 'Design', defaultUsers: 'alice@doist.com' }, + 69, + ).then((args) => { + resolved(args) + return args + }) + + await vi.waitFor(() => expect(apiMocks.getWorkspaceUsers).toHaveBeenCalled()) + expect(resolved).not.toHaveBeenCalled() + + pendingUsers.resolve([{ id: 10, fullName: 'Alice', email: 'alice@doist.com' }]) + expect(resolved).not.toHaveBeenCalled() + pendingGroups.resolve(groups) + + await expect(result).resolves.toEqual({ + useDefaultRecipients: true, + defaultGroups: ['GR1'], + defaultRecipients: [10], + }) + }) +}) diff --git a/src/commands/channel/default-audience.ts b/src/commands/channel/default-audience.ts index 23ed8fa..3165f03 100644 --- a/src/commands/channel/default-audience.ts +++ b/src/commands/channel/default-audience.ts @@ -1,4 +1,4 @@ -import type { UpdateChannelArgs } from '@doist/comms-sdk' +import type { Group, UpdateChannelArgs } from '@doist/comms-sdk' import { getWorkspaceGroups } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' import { parseRef, resolveGroupRef, resolveUserRefs } from '../../lib/refs.js' @@ -41,20 +41,29 @@ export async function buildDefaultAudienceArgs( options: DefaultAudienceOptions, workspaceId: number, ): Promise { - const args: DefaultAudienceArgs = {} + let groupsPromise: Promise | undefined if (options.defaultGroups !== undefined) { const refs = options.defaultGroups.split(',').map((ref) => ref.trim()) const workspaceGroups = refs.some((ref) => parseRef(ref).type === 'name') ? getWorkspaceGroups(workspaceId) : undefined - const groups = await Promise.all( + groupsPromise = Promise.all( refs.map((ref) => resolveGroupRef(ref, workspaceId, workspaceGroups)), ) + } + const usersPromise = + options.defaultUsers !== undefined + ? resolveUserRefs(options.defaultUsers, workspaceId) + : undefined + const [groups, userIds] = await Promise.all([groupsPromise, usersPromise]) + + const args: DefaultAudienceArgs = {} + if (groups !== undefined) { args.defaultGroups = groups.map((group) => group.id) args.useDefaultRecipients = true } - if (options.defaultUsers !== undefined) { - args.defaultRecipients = await resolveUserRefs(options.defaultUsers, workspaceId) + if (userIds !== undefined) { + args.defaultRecipients = userIds args.useDefaultRecipients = true } return args