Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 70 additions & 8 deletions src/commands/thread/thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ vi.mock('../../lib/input.js', () => ({
vi.mock('chalk')

import { clearWorkspaceUserCache } from '../../lib/api.js'
import { CliError } from '../../lib/errors.js'
import { openEditor, readStdin, readStdinToEnd } from '../../lib/input.js'
import { registerThreadCommand } from './index.js'

Expand Down Expand Up @@ -186,6 +187,14 @@ function createClient({

const createProgram = () => createTestProgram(registerThreadCommand)

async function viewOutput(client: ReturnType<typeof createClient>, args: string[]) {
apiMocks.getCommsClient.mockResolvedValue(client)
const program = createProgram()
const consoleSpy = captureConsole('log')
await program.parseAsync(['node', 'tdc', 'thread', 'view', '500', ...args])
return consoleSpy.mock.calls.map((c) => c[0]).join('\n')
}

// Shared setup for the --file suites: a fresh mock client wired into getCommsClient
// plus a program. Tests asserting on output call captureConsole('log') themselves.
function setupFileTest() {
Expand Down Expand Up @@ -736,14 +745,6 @@ describe('thread view comment count header', () => {

const users = { 1: { id: 1, fullName: 'Alice' }, 2: { id: 2, fullName: 'Bob' } }

async function viewOutput(client: ReturnType<typeof createClient>, args: string[]) {
apiMocks.getCommsClient.mockResolvedValue(client)
const program = createProgram()
const consoleSpy = captureConsole('log')
await program.parseAsync(['node', 'tdc', 'thread', 'view', '500', ...args])
return consoleSpy.mock.calls.map((c) => c[0]).join('\n')
}

it('shows the plain count when every comment came back', async () => {
const client = createClient({
comments: [createComment(1, 1), createComment(2, 2), createComment(3, 3)],
Expand Down Expand Up @@ -850,6 +851,67 @@ describe('thread view error propagation', () => {
})
})

describe('thread view when the channel lookup fails', () => {
beforeEach(() => {
clearWorkspaceUserCache()
vi.clearAllMocks()
configMocks.getConfig.mockResolvedValue({})
groupsMock.getWorkspaceGroups.mockResolvedValue([])
groupsMock.getWorkspaceUsers.mockResolvedValue([])
})

const rateLimited = new CliError(
'RATE_LIMITED',
'Comms is rate limiting requests: 429 Too Many Requests.',
)

function clientWithChannelError(error: CliError) {
const client = createClient({ comments: [createComment(1, 1)] })
client.channels.getChannel.mockRejectedValue(error)
return client
}

it('shows the thread with the channel id when rate limited', async () => {
const output = await viewOutput(clientWithChannelError(rateLimited), [])

expect(output).toContain('Test Thread')
expect(output).toContain('[ch:CH100]')
expect(output).toContain('Comment 1')
})

it('sets channelName to null in --json output when rate limited', async () => {
const output = JSON.parse(await viewOutput(clientWithChannelError(rateLimited), ['--json']))

expect(output.thread).toMatchObject({ channelId: 'CH100', channelName: null })
expect(output.comments).toHaveLength(1)
})

it('sets channelName to null in --ndjson output when rate limited', async () => {
const output = await viewOutput(clientWithChannelError(rateLimited), ['--ndjson'])
const [threadLine, commentLine] = output.split('\n').map((line) => JSON.parse(line))

expect(threadLine).toMatchObject({ type: 'thread', channelId: 'CH100', channelName: null })
expect(commentLine).toMatchObject({ type: 'comment', id: '1' })
})

it('sets channelName to null for a single comment when rate limited', async () => {
const output = JSON.parse(
await viewOutput(clientWithChannelError(rateLimited), ['--comment', '1', '--json']),
)

expect(output).toMatchObject({ id: '1', channelName: null })
})

it('still fails on any other error', async () => {
const notFound = new CliError('NOT_FOUND', 'Comms could not find that resource: 404.')

await expect(viewOutput(clientWithChannelError(notFound), [])).rejects.toHaveProperty(
'code',
'NOT_FOUND',
)
})
})

describe('thread create', () => {
beforeEach(() => {
clearWorkspaceUserCache()
Expand Down
44 changes: 31 additions & 13 deletions src/commands/thread/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CommsApi } from '@doist/comms-sdk'
import chalk from 'chalk'
import { buildUserNameMap, getCommsClient } from '../../lib/api.js'
import { formatRelativeDate } from '../../lib/dates.js'
import { isCliErrorCode } from '../../lib/errors.js'
import { renderMarkdown } from '../../lib/markdown.js'
import { toDate, type PaginatedViewOptions } from '../../lib/options.js'
import { colors, formatJson, pluralize } from '../../lib/output.js'
Expand Down Expand Up @@ -33,6 +34,27 @@ function formatCommentsHeader(
return `--- ${label} ---`
}

// Comms rate limits channel lookups per user, so a bulk read of threads hits that limit first.
// The name is only a label, so a rate-limited lookup returns null instead of failing the view.
async function getChannelName(client: CommsApi, channelId: string): Promise<string | null> {
try {
const channel = await client.channels.getChannel(channelId)
return channel.name
} catch (error) {
if (isCliErrorCode(error, 'RATE_LIMITED')) return null

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 catch only tolerates RATE_LIMITED from getChannel, but the default viewThread path calls assertChannelIsPublic(thread.channelId, thread.workspaceId) before reaching getChannelName. That helper issues a separate channels.getChannels call through the same wrapped client, so a 429 there still throws RATE_LIMITED and aborts the view before this guard runs. The new tests mock assertChannelIsPublic, so that path isn't exercised. Handle the public-channel lookup's rate limit the same way (or otherwise keep it from making an unguarded channel call) so the default and --json views actually survive the rate limit.

throw error
}
}

function printThreadHeader(
thread: { title: string; channelId: string },
channelName: string | null,
) {
console.log(chalk.bold(thread.title))
console.log(colors.channel(`[${channelName ?? `ch:${thread.channelId}`}]`))
console.log('')
}

async function viewSingleComment(
client: CommsApi,
threadId: string,
Expand All @@ -44,16 +66,16 @@ async function viewSingleComment(
client.comments.getComment(commentId),
])

const [channel, userMap] = await Promise.all([
client.channels.getChannel(thread.channelId),
const [channelName, userMap] = await Promise.all([
getChannelName(client, thread.channelId),
buildUserNameMap(thread.workspaceId, client),
])

if (options.json) {
const output = {
...comment,
creatorName: userMap.get(comment.creator),
channelName: channel.name,
channelName,
threadTitle: thread.title,
}
console.log(formatJson(output, undefined, options.full))
Expand All @@ -71,9 +93,7 @@ async function viewSingleComment(
return
}

console.log(chalk.bold(thread.title))
console.log(colors.channel(`[${channel.name}]`))
console.log('')
printThreadHeader(thread, channelName)
await printComment(comment, userMap, options.raw ?? false)
}

Expand Down Expand Up @@ -133,16 +153,16 @@ export async function viewThread(ref: string, options: ViewOptions): Promise<voi
}
}

const [channel, userMap] = await Promise.all([
client.channels.getChannel(thread.channelId),
const [channelName, userMap] = await Promise.all([
getChannelName(client, thread.channelId),
buildUserNameMap(thread.workspaceId, client),
])

if (options.json) {
const output = {
thread: {
...thread,
channelName: channel.name,
channelName,
creatorName: userMap.get(thread.creator),
},
comments: displayComments.map((c) => ({
Expand All @@ -158,7 +178,7 @@ export async function viewThread(ref: string, options: ViewOptions): Promise<voi
const threadOutput = {
type: 'thread',
...thread,
channelName: channel.name,
channelName,
creatorName: userMap.get(thread.creator),
}
console.log(JSON.stringify(threadOutput))
Expand All @@ -170,9 +190,7 @@ export async function viewThread(ref: string, options: ViewOptions): Promise<voi
return
}

console.log(chalk.bold(thread.title))
console.log(colors.channel(`[${channel.name}]`))
console.log('')
printThreadHeader(thread, channelName)

if (options.unread) {
const creatorName = userMap.get(thread.creator) || `user:${thread.creator}`
Expand Down
5 changes: 4 additions & 1 deletion src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,10 @@ describe('wrapResult — central 403 translation', () => {
await expect(client.channels.deleteChannel('CH429')).rejects.toMatchObject({
code: 'RATE_LIMITED',
message: 'Comms is rate limiting requests: 429 Too Many Requests.',
hints: ['Wait a moment, then retry'],
hints: [
'Wait a minute or two before retrying',
'Retrying sooner keeps the limit in place, because rejected calls count against it too',
],
})
})

Expand Down
6 changes: 5 additions & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,14 @@ function wrapResult(
)
}
if (isRateLimited(error)) {
// Agents follow this hint literally, so it says how long to wait.
throw new CliError(
'RATE_LIMITED',
'Comms is rate limiting requests: 429 Too Many Requests.',
['Wait a moment, then retry'],
[
'Wait a minute or two before retrying',
'Retrying sooner keeps the limit in place, because rejected calls count against it too',
],
)
}
if (isServerError(error)) {
Expand Down
Loading