diff --git a/README.md b/README.md index 00c3769..ecc80bf 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,15 @@ tdc thread reply # reply to a thread tdc thread reply "Update" --notify NONE # reply without notifying anyone tdc thread rename "New title" # rename a thread tdc thread update "New body" # edit a thread's body (first post) +tdc thread done --yes # archive a thread (mark done) +tdc thread undone --yes # move it back to your inbox +tdc thread mark-read # mark a thread read +tdc thread mark-unread # mark it unread again (--from for part of it) tdc conversation unread # list unread conversations tdc conversation list # list conversations (--kind, --participant, --name, --state) tdc conversation view # view conversation messages +tdc conversation done --yes # archive a conversation +tdc conversation undone --yes # unarchive it tdc msg view # view a conversation message tdc search "keyword" # search across workspace tdc search "keyword" --all # fetch all result pages diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 2471f9f..a92190f 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -118,9 +118,14 @@ tdc thread reply "content" --file ./a.png # Attach a file (repeatable; co tdc thread done # Preview thread archive (requires --yes to execute) tdc thread done --yes # Archive thread (mark done) tdc thread done --yes --json # Archive and return status as JSON +tdc thread undone --yes # Unarchive thread (move it back to your inbox); inverse of done +tdc thread undone --yes --json # Unarchive and return status as JSON tdc thread mark-read # Mark a thread read tdc thread mark-read --yes # Mark multiple threads read printf "id:CbT8n2Kp4Qx6Rz9Lm3Va\nid:CbT9m4Qr7Vz2Nx8Lp5Sa\n" | tdc thread mark-read --dry-run # Preview bulk mark-read from stdin +tdc thread mark-unread # Mark a whole thread unread; inverse of mark-read +tdc thread mark-unread --from # Mark unread from that comment onward (single thread only) +tdc thread mark-unread --yes # Mark multiple threads unread (also accepts refs on stdin) tdc thread mute # Mute thread for 60 minutes (default) tdc thread mute --minutes 480 # Mute for custom duration tdc thread mute --json # Mute and return { id, mutedUntil } as JSON @@ -189,6 +194,8 @@ tdc conversation reply "content" --file ./a.png # Attach a file (repeatab tdc conversation done # Preview conversation archive (requires --yes to execute) tdc conversation done --yes # Archive conversation tdc conversation done --yes --json # Archive and return status as JSON +tdc conversation undone --yes # Unarchive conversation; inverse of done +tdc conversation undone --yes --json # Unarchive and return status as JSON tdc conversation mute # Mute conversation for 60 minutes (default) tdc conversation mute --minutes 480 # Mute for custom duration tdc conversation mute --json # Mute and return { id, mutedUntil } as JSON @@ -446,7 +453,7 @@ echo "Quick reply" | tdc conversation reply If no content argument is provided and no stdin is piped, the CLI opens `$EDITOR` for interactive input. In non-TTY environments (e.g. when called by an agent or in a pipeline), the editor is automatically skipped and the command fails fast with an actionable error message. Use `--non-interactive` to force this behavior even in a TTY, or `--interactive` to override auto-detection. -`tdc thread mark-read` also accepts thread refs from stdin, one per line: +`tdc thread mark-read` and `tdc thread mark-unread` also accept thread refs from stdin, one per line: ```bash printf "id:CbT8n2Kp4Qx6Rz9Lm3Va\nid:CbT9m4Qr7Vz2Nx8Lp5Sa\n" | tdc thread mark-read --yes @@ -469,6 +476,7 @@ tdc inbox --unread --json tdc thread view --unread tdc thread reply "Thanks, I'll look into this." tdc thread done --yes +tdc thread undone --yes # Changed your mind: back to the inbox ``` **Search and review:** diff --git a/src/commands/conversation/archive.ts b/src/commands/conversation/archive.ts new file mode 100644 index 0000000..f43b4e3 --- /dev/null +++ b/src/commands/conversation/archive.ts @@ -0,0 +1,64 @@ +import { getCommsClient } from '../../lib/api.js' +import { CliError } from '../../lib/errors.js' +import { formatJson, printDryRun } from '../../lib/output.js' +import { resolveConversationId } from '../../lib/refs.js' +import { conversationLabel, type DoneOptions } from './helpers.js' + +async function setConversationArchiveState( + ref: string, + options: DoneOptions, + archive: boolean, +): Promise { + const action = archive ? 'archive' : 'unarchive' + const conversationId = resolveConversationId(ref) + + const client = await getCommsClient() + const conversation = await client.conversations.getConversation(conversationId) + + const noop = conversation.archived === archive + + if (options.dryRun) { + printDryRun(`${action} conversation`, { + Conversation: conversationLabel(conversation), + Status: noop ? (archive ? 'already archived' : 'not archived') : undefined, + }) + return + } + + if (!options.yes) { + if (options.json) { + throw new CliError( + 'MISSING_YES_FLAG', + `--yes is required to execute ${action} in --json mode.`, + ) + } + console.log(`Would ${action}: ${conversationLabel(conversation)}`) + console.log('Use --yes to confirm.') + return + } + + if (!noop) { + if (archive) { + await client.conversations.archiveConversation(conversationId) + } else { + await client.conversations.unarchiveConversation(conversationId) + } + } + + if (options.json) { + console.log(formatJson({ id: conversationId, archived: archive })) + return + } + + console.log( + `Conversation ${conversationId} ${action}d${noop ? ' (already in target state)' : ''}.`, + ) +} + +export async function markConversationDone(ref: string, options: DoneOptions): Promise { + await setConversationArchiveState(ref, options, true) +} + +export async function markConversationUndone(ref: string, options: DoneOptions): Promise { + await setConversationArchiveState(ref, options, false) +} diff --git a/src/commands/conversation/conversation.test.ts b/src/commands/conversation/conversation.test.ts index 16dfc80..3910189 100644 --- a/src/commands/conversation/conversation.test.ts +++ b/src/commands/conversation/conversation.test.ts @@ -114,6 +114,7 @@ function createClient({ getUnread: vi.fn().mockResolvedValue({ data: [], version: 1 }), getConversation: vi.fn(async (id: string) => conversationsById.get(id)), archiveConversation: vi.fn(), + unarchiveConversation: vi.fn(), muteConversation: vi.fn(async ({ id, minutes }: { id: string; minutes: number }) => ({ ...conversationsById.get(id), mutedUntil: new Date(Date.now() + minutes * 60000), @@ -1225,6 +1226,146 @@ describe('conversation done', () => { }) }) +describe('conversation undone', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('unarchives a conversation with --yes', async () => { + const conversation = { + ...createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z'), + archived: true, + } + const client = createClient({ archivedConversations: [conversation] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42', '--yes']) + + expect(client.conversations.unarchiveConversation).toHaveBeenCalledWith('42') + expect(client.conversations.archiveConversation).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 unarchived.') + }) + + it('skips the write when the conversation is not archived', async () => { + const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') + const client = createClient({ activeConversations: [conversation] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42', '--yes']) + + expect(client.conversations.unarchiveConversation).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith( + 'Conversation 42 unarchived (already in target state).', + ) + }) + + it('prompts for confirmation without --yes', async () => { + const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') + const client = createClient({ archivedConversations: [conversation] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42']) + + expect(consoleSpy).toHaveBeenCalledWith('Would unarchive: conversation 42') + expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') + expect(client.conversations.unarchiveConversation).not.toHaveBeenCalled() + }) + + it('outputs JSON with --json --yes', async () => { + const conversation = { + ...createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z'), + archived: true, + } + const client = createClient({ archivedConversations: [conversation] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42', '--json', '--yes']) + + expect(client.conversations.unarchiveConversation).toHaveBeenCalledWith('42') + const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(jsonOutput).toEqual({ id: '42', archived: false }) + }) + + it('errors when --json is used without --yes', async () => { + const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') + const client = createClient({ archivedConversations: [conversation] }) + apiMocks.getCommsClient.mockResolvedValue(client) + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42', '--json']), + ).rejects.toHaveProperty('code', 'MISSING_YES_FLAG') + + expect(client.conversations.unarchiveConversation).not.toHaveBeenCalled() + }) + + it('shows dry run output for an archived conversation without a status line', async () => { + const conversation = { + ...createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z'), + archived: true, + } + const client = createClient({ archivedConversations: [conversation] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42', '--dry-run']) + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Would unarchive conversation'), + ) + expect(consoleSpy).toHaveBeenCalledWith(' Conversation: conversation 42') + expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Status:')) + expect(client.conversations.unarchiveConversation).not.toHaveBeenCalled() + }) + + it('shows dry run output and flags a conversation that is not archived', async () => { + const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') + const client = createClient({ activeConversations: [conversation] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42', '--dry-run']) + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Would unarchive conversation'), + ) + expect(consoleSpy).toHaveBeenCalledWith(' Conversation: conversation 42') + expect(consoleSpy).toHaveBeenCalledWith(' Status: not archived') + expect(client.conversations.unarchiveConversation).not.toHaveBeenCalled() + }) + + it('runs validation in dry-run mode', async () => { + const client = createClient({ activeConversations: [] }) + client.conversations.getConversation.mockRejectedValueOnce( + new Error('conversation not found'), + ) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42', '--dry-run']), + ).rejects.toThrow('conversation not found') + expect(client.conversations.unarchiveConversation).not.toHaveBeenCalled() + }) +}) + describe('conversation reply --file', () => { const files = useFileFixtures('tdc-convo-reply-', 'photo.png', 'doc.pdf') diff --git a/src/commands/conversation/done.ts b/src/commands/conversation/done.ts deleted file mode 100644 index 648bafd..0000000 --- a/src/commands/conversation/done.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getCommsClient } from '../../lib/api.js' -import { CliError } from '../../lib/errors.js' -import { formatJson, printDryRun } from '../../lib/output.js' -import { resolveConversationId } from '../../lib/refs.js' -import { conversationLabel, type DoneOptions } from './helpers.js' - -export async function markConversationDone(ref: string, options: DoneOptions): Promise { - const conversationId = resolveConversationId(ref) - - const client = await getCommsClient() - const conversation = await client.conversations.getConversation(conversationId) - - if (options.dryRun) { - printDryRun('archive conversation', { - Conversation: conversationLabel(conversation), - Status: conversation.archived ? 'already archived' : undefined, - }) - return - } - - if (!options.yes) { - if (options.json) { - throw new CliError( - 'MISSING_YES_FLAG', - '--yes is required to execute archive in --json mode.', - ) - } - console.log(`Would archive: ${conversationLabel(conversation)}`) - console.log('Use --yes to confirm.') - return - } - - await client.conversations.archiveConversation(conversationId) - - if (options.json) { - console.log(formatJson({ id: conversationId, archived: true })) - return - } - - console.log(`Conversation ${conversationId} archived.`) -} diff --git a/src/commands/conversation/index.ts b/src/commands/conversation/index.ts index 339e702..86f0a59 100644 --- a/src/commands/conversation/index.ts +++ b/src/commands/conversation/index.ts @@ -1,7 +1,7 @@ import { Command, Option } from 'commander' import { withCaseInsensitiveChoices } from '../../lib/completion.js' import { collect } from '../../lib/options.js' -import { markConversationDone } from './done.js' +import { markConversationDone, markConversationUndone } from './archive.js' import { listConversations } from './list.js' import { muteConversation } from './mute.js' import { replyToConversation } from './reply.js' @@ -160,6 +160,21 @@ Examples: ) .action(markConversationDone) + conversation + .command('undone ') + .description('Unarchive a conversation; inverse of done') + .option('--yes', 'Confirm unarchive') + .option('--dry-run', 'Show what would happen without executing') + .option('--json', 'Output result as JSON') + .addHelpText( + 'after', + ` +Examples: + tdc conversation undone id:CbV8n2Kp4Qx6Rz9Lm3Va --yes + tdc conversation undone id:CbV8n2Kp4Qx6Rz9Lm3Va --dry-run`, + ) + .action(markConversationUndone) + conversation .command('mute ') .description('Mute a conversation (stop notifications)') diff --git a/src/commands/thread/helpers.ts b/src/commands/thread/helpers.ts index e39c44f..89a986a 100644 --- a/src/commands/thread/helpers.ts +++ b/src/commands/thread/helpers.ts @@ -1,10 +1,15 @@ +import type { CommsApi, Thread } from '@doist/comms-sdk' import chalk from 'chalk' -import { getWorkspaceGroups, getWorkspaceUsers } from '../../lib/api.js' +import { getCommsClient, getWorkspaceGroups, getWorkspaceUsers } from '../../lib/api.js' import { formatRelativeDate } from '../../lib/dates.js' +import { CliError } from '../../lib/errors.js' import { isAccessible } from '../../lib/global-args.js' +import { readStdinToEnd } from '../../lib/input.js' import { renderMarkdown } from '../../lib/markdown.js' -import { colors } from '../../lib/output.js' -import { partitionNotifyIds } from '../../lib/refs.js' +import type { MutationOptions } from '../../lib/options.js' +import { colors, formatJson, pluralize } from '../../lib/output.js' +import { assertChannelIsPublic } from '../../lib/public-channels.js' +import { partitionNotifyIds, resolveThreadId } from '../../lib/refs.js' export function printSeparator(label: string): void { const totalWidth = 60 @@ -75,3 +80,193 @@ export async function resolveNotifyIds( export function formatNotifyLabel(items: NamedEntity[]): string { return items.map((i) => `${i.name} (${i.id})`).join(', ') } + +// Shared by `mark-read` and `mark-unread`: the bulk-ref loop, the per-workspace +// unread lookup, confirmation, and output. Each verb supplies a per-thread plan. + +export type ReadStateTextStatus = 'changed' | 'preview' | 'unchanged' + +export type ThreadReadState = { + thread: Thread + /** + * Object index of the last comment the user has read, or `null` when the + * thread is fully read (absent from the workspace's unread list). `-1` + * means nothing has been read, including the thread body. + */ + lastReadObjIndex: number | null +} + +export type ReadStatePlan = { + /** Appended to messages, e.g. `' from comment X'`; empty for the whole thread. */ + scope: string + isUnchanged(state: ThreadReadState): boolean + /** JSON row; `outcome` is `'unchanged'` when nothing needs to move. */ + status(state: ThreadReadState, outcome: 'planned' | 'unchanged'): Status + /** Performs the mutation and returns the thread's new `lastReadObjIndex`. */ + apply(client: CommsApi, state: ThreadReadState): Promise +} + +export type ReadStateMutation = { + verb: 'read' | 'unread' + /** Runs on the full ref list (positional plus stdin) before anything is loaded. */ + validateRefs?(rawRefs: string[]): void + /** + * Builds the per-thread plan. Runs before the unread lookup so an invalid + * option (a bad `--from` ref) fails without a workspace-wide request. + */ + plan(client: CommsApi, threadId: string): Promise> +} + +export async function runThreadReadStateMutation( + refs: string[], + options: MutationOptions, + mutation: ReadStateMutation, +): Promise { + const rawRefs = await collectThreadRefs(refs) + if (rawRefs.length === 0) { + throw new CliError( + 'INVALID_REF', + 'No thread references provided. Pass refs as arguments or pipe them via stdin.', + ) + } + mutation.validateRefs?.(rawRefs) + + const needsConfirmation = rawRefs.length > 1 && !options.yes && !options.dryRun + if (options.json && needsConfirmation) { + throw new CliError( + 'MISSING_YES_FLAG', + `--yes is required to execute bulk mark-${mutation.verb} in --json mode.`, + ) + } + + const client = await getCommsClient() + const unreadCache = new Map>() + const jsonStatuses: Status[] = [] + const textStatuses: ReadStateTextStatus[] = [] + + for (const rawRef of rawRefs) { + const threadId = resolveThreadId(rawRef) + const plan = await mutation.plan(client, threadId) + const state = await loadThreadReadState(client, unreadCache, threadId) + const label = threadLabel(state.thread) + + if (plan.isUnchanged(state)) { + jsonStatuses.push(plan.status(state, 'unchanged')) + textStatuses.push('unchanged') + if (!options.json) { + console.log(`Thread ${label} is already ${mutation.verb}${plan.scope}.`) + } + continue + } + + if (needsConfirmation || options.dryRun) { + jsonStatuses.push(plan.status(state, 'planned')) + textStatuses.push('preview') + if (!options.json) { + const prefix = options.dryRun ? 'Dry run: would' : 'Would' + console.log(`${prefix} mark ${mutation.verb} thread ${label}${plan.scope}.`) + } + continue + } + + const lastReadObjIndex = await plan.apply(client, state) + const unreadByThread = unreadCache.get(state.thread.workspaceId) + if (lastReadObjIndex === null) { + unreadByThread?.delete(threadId) + } else { + unreadByThread?.set(threadId, lastReadObjIndex) + } + + jsonStatuses.push(plan.status(state, 'planned')) + textStatuses.push('changed') + if (!options.json) { + console.log(`Thread ${label} marked ${mutation.verb}${plan.scope}.`) + } + } + + if (options.json) { + console.log( + formatJson( + options.dryRun + ? jsonStatuses.map((status) => ({ ...status, dryRun: true })) + : jsonStatuses, + ), + ) + return + } + + if (rawRefs.length > 1) { + printReadStateSummary(textStatuses) + } + + if (needsConfirmation) { + console.log('Use --yes to confirm.') + } +} + +async function collectThreadRefs(refs: string[]): Promise { + const inlineRefs = refs.map((ref) => ref.trim()).filter(Boolean) + + const stdinContent = await readStdinToEnd() + if (!stdinContent) return inlineRefs + + const stdinRefs = stdinContent + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line !== '' && !line.startsWith('#')) + + return [...inlineRefs, ...stdinRefs] +} + +/** + * Loads a thread and its unread position. `unreadCache` maps a workspace id to + * its unread threads (`threadId` -> last read `objIndex`) so bulk runs fetch + * the unread list once per workspace. + */ +async function loadThreadReadState( + client: CommsApi, + unreadCache: Map>, + threadId: string, +): Promise { + const thread = await client.threads.getThread(threadId) + await assertChannelIsPublic(thread.channelId, thread.workspaceId) + + let unreadByThread = unreadCache.get(thread.workspaceId) + if (!unreadByThread) { + const unread = await client.threads.getUnread(thread.workspaceId) + unreadByThread = new Map( + unread.data.map((unreadThread) => [unreadThread.threadId, unreadThread.objIndex]), + ) + unreadCache.set(thread.workspaceId, unreadByThread) + } + + return { thread, lastReadObjIndex: unreadByThread.get(thread.id) ?? null } +} + +export function threadLabel(thread: Pick): string { + return `${thread.title} (${thread.id})` +} + +function printReadStateSummary(statuses: ReadStateTextStatus[]): void { + const summary = [ + summarizeStatus(statuses, 'changed'), + summarizeStatus(statuses, 'unchanged'), + summarizeStatus(statuses, 'preview'), + ].filter(Boolean) + + console.log('') + console.log(`Summary: ${summary.join(', ')}`) +} + +function summarizeStatus( + statuses: ReadStateTextStatus[], + status: ReadStateTextStatus, +): string | null { + const count = statuses.filter((value) => value === status).length + if (count === 0) { + return null + } + + const noun = status === 'preview' ? pluralize(count, 'preview') : pluralize(count, 'thread') + return status === 'preview' ? `${count} ${noun}` : `${count} ${status} ${noun}` +} diff --git a/src/commands/thread/index.ts b/src/commands/thread/index.ts index dc17c9c..c4d4c7f 100644 --- a/src/commands/thread/index.ts +++ b/src/commands/thread/index.ts @@ -3,11 +3,12 @@ import { withUnvalidatedChoices } from '../../lib/completion.js' import { collect } from '../../lib/options.js' import { createThread } from './create.js' import { deleteThread } from './delete.js' -import { markThreadDone } from './mutate.js' +import { markThreadDone, markThreadUndone } from './mutate.js' import { muteThread, unmuteThread } from './mute.js' import { markThreadRead } from './read.js' import { renameThread } from './rename.js' import { replyToThread } from './reply.js' +import { markThreadUnread } from './unread.js' import { updateThread } from './update.js' import { viewThread } from './view.js' @@ -117,6 +118,22 @@ Examples: ) .action(markThreadDone) + thread + .command('undone ') + .description('Unarchive a thread (move it back to your inbox); inverse of done') + .option('--yes', 'Confirm unarchive') + .option('--dry-run', 'Show what would happen without executing') + .option('--json', 'Output result as JSON') + .addHelpText( + 'after', + ` +Examples: + tdc thread undone id:CbT8n2Kp4Qx6Rz9Lm3Va --yes + tdc thread undone id:CbT8n2Kp4Qx6Rz9Lm3Va --dry-run + tdc thread undone id:CbT8n2Kp4Qx6Rz9Lm3Va --json --yes`, + ) + .action(markThreadUndone) + const markReadCmd = thread .command('mark-read [thread-refs...]') .description('Mark a thread read for the current user') @@ -139,6 +156,33 @@ Examples: return markThreadRead(refs, options) }) + const markUnreadCmd = thread + .command('mark-unread [thread-refs...]') + .description('Mark a thread unread for the current user; inverse of mark-read') + .option( + '--from ', + 'Mark unread from this comment onward (single thread only; default: whole thread)', + ) + .option('--yes', 'Skip confirmation for bulk operations') + .option('--dry-run', 'Show what would happen without executing') + .option('--json', 'Output result as JSON') + .addHelpText( + 'after', + ` +Examples: + tdc thread mark-unread id:CbT8n2Kp4Qx6Rz9Lm3Va + tdc thread mark-unread id:CbT8n2Kp4Qx6Rz9Lm3Va --from id:CbM8n2Kp4Qx6Rz9Lm3Va + tdc thread mark-unread id:CbT8n2Kp4Qx6Rz9Lm3Va id:CbT9m4Qr7Vz2Nx8Lp5Sa --yes + printf "id:CbT8n2Kp4Qx6Rz9Lm3Va\\nid:CbT9m4Qr7Vz2Nx8Lp5Sa\\n" | tdc thread mark-unread --yes`, + ) + .action((refs, options) => { + if (refs.length === 0 && process.stdin.isTTY) { + markUnreadCmd.help() + return + } + return markThreadUnread(refs, options) + }) + thread .command('delete ') .description('Permanently delete a thread') diff --git a/src/commands/thread/mutate.ts b/src/commands/thread/mutate.ts index 3ef90e8..4264d32 100644 --- a/src/commands/thread/mutate.ts +++ b/src/commands/thread/mutate.ts @@ -4,17 +4,26 @@ import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' import { assertChannelIsPublic } from '../../lib/public-channels.js' import { resolveThreadId } from '../../lib/refs.js' +import { threadLabel } from './helpers.js' -export async function markThreadDone(ref: string, options: MutationOptions): Promise { +async function setThreadArchiveState( + ref: string, + options: MutationOptions, + archive: boolean, +): Promise { + const action = archive ? 'archive' : 'unarchive' const threadId = resolveThreadId(ref) const client = await getCommsClient() const thread = await client.threads.getThread(threadId) await assertChannelIsPublic(thread.channelId, thread.workspaceId) + const noop = thread.isArchived === archive + if (options.dryRun) { - printDryRun('archive thread', { - Thread: `${thread.title} (${threadId})`, + printDryRun(`${action} thread`, { + Thread: threadLabel(thread), + Status: noop ? (archive ? 'already archived' : 'already in inbox') : undefined, }) return } @@ -23,20 +32,34 @@ export async function markThreadDone(ref: string, options: MutationOptions): Pro if (options.json) { throw new CliError( 'MISSING_YES_FLAG', - '--yes is required to execute archive in --json mode.', + `--yes is required to execute ${action} in --json mode.`, ) } - console.log(`Would archive: ${thread.title}`) + console.log(`Would ${action}: ${threadLabel(thread)}`) console.log('Use --yes to confirm.') return } - await client.inbox.archiveThread(threadId) + if (!noop) { + if (archive) { + await client.inbox.archiveThread(threadId) + } else { + await client.inbox.unarchiveThread(threadId) + } + } if (options.json) { - console.log(formatJson({ id: threadId, isArchived: true })) + console.log(formatJson({ id: threadId, isArchived: archive })) return } - console.log(`Thread ${threadId} archived.`) + console.log(`Thread ${threadId} ${action}d${noop ? ' (already in target state)' : ''}.`) +} + +export async function markThreadDone(ref: string, options: MutationOptions): Promise { + await setThreadArchiveState(ref, options, true) +} + +export async function markThreadUndone(ref: string, options: MutationOptions): Promise { + await setThreadArchiveState(ref, options, false) } diff --git a/src/commands/thread/read.ts b/src/commands/thread/read.ts index b1df3a4..5648192 100644 --- a/src/commands/thread/read.ts +++ b/src/commands/thread/read.ts @@ -1,131 +1,33 @@ -import type { CommsApi, Thread } from '@doist/comms-sdk' -import { getCommsClient } from '../../lib/api.js' -import { CliError } from '../../lib/errors.js' -import { readStdinToEnd } from '../../lib/input.js' +import type { Thread } from '@doist/comms-sdk' import type { MutationOptions } from '../../lib/options.js' -import { formatJson, pluralize } from '../../lib/output.js' -import { assertChannelIsPublic } from '../../lib/public-channels.js' -import { resolveThreadId } from '../../lib/refs.js' +import { runThreadReadStateMutation } from './helpers.js' export type MarkThreadReadOptions = MutationOptions -type LoadedThread = { - thread: Thread - isUnread: boolean -} - type MarkReadStatus = { id: string isRead: true } -type TextStatus = 'changed' | 'preview' | 'unchanged' - export async function markThreadRead( refs: string[], options: MarkThreadReadOptions, ): Promise { - const rawRefs = await collectThreadRefs(refs) - if (rawRefs.length === 0) { - throw new CliError( - 'INVALID_REF', - 'No thread references provided. Pass refs as arguments or pipe them via stdin.', - ) - } - - const needsConfirmation = rawRefs.length > 1 && !options.yes && !options.dryRun - if (options.json && needsConfirmation) { - throw new CliError( - 'MISSING_YES_FLAG', - '--yes is required to execute bulk mark-read in --json mode.', - ) - } - - const client = await getCommsClient() - const unreadCache = new Map>() - const jsonStatuses: MarkReadStatus[] = [] - const textStatuses: TextStatus[] = [] - - for (const rawRef of rawRefs) { - const threadId = resolveThreadId(rawRef) - const loaded = await loadThread(client, unreadCache, threadId) - - if (!loaded.isUnread) { - jsonStatuses.push({ id: threadId, isRead: true }) - textStatuses.push('unchanged') - if (!options.json) { - console.log(`Thread ${threadLabel(loaded.thread)} is already read.`) - } - continue - } - - if (needsConfirmation || options.dryRun) { - jsonStatuses.push({ id: threadId, isRead: true }) - textStatuses.push('preview') - if (!options.json) { - const prefix = options.dryRun ? 'Dry run: would' : 'Would' - console.log(`${prefix} mark read thread ${threadLabel(loaded.thread)}.`) - } - continue - } - - await client.threads.markRead({ - id: threadId, - objIndex: getLatestObjIndex(loaded.thread), - }) - unreadCache.get(loaded.thread.workspaceId)?.delete(threadId) - - jsonStatuses.push({ id: threadId, isRead: true }) - textStatuses.push('changed') - if (!options.json) { - console.log(`Thread ${threadLabel(loaded.thread)} marked read.`) - } - } - - if (options.json && !options.dryRun) { - console.log(formatJson(jsonStatuses)) - return - } - - if (!options.json && rawRefs.length > 1) { - printSummary(textStatuses) - } - - if (!options.json && needsConfirmation) { - console.log('Use --yes to confirm.') - } -} - -async function collectThreadRefs(refs: string[]): Promise { - const inlineRefs = refs.map((ref) => ref.trim()).filter(Boolean) - - const stdinContent = await readStdinToEnd() - if (!stdinContent) return inlineRefs - - const stdinRefs = stdinContent - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line !== '' && !line.startsWith('#')) - - return [...inlineRefs, ...stdinRefs] -} - -async function loadThread( - client: CommsApi, - unreadCache: Map>, - threadId: string, -): Promise { - const thread = await client.threads.getThread(threadId) - await assertChannelIsPublic(thread.channelId, thread.workspaceId) - - let unreadIds = unreadCache.get(thread.workspaceId) - if (!unreadIds) { - const unread = await client.threads.getUnread(thread.workspaceId) - unreadIds = new Set(unread.data.map((unreadThread) => unreadThread.threadId)) - unreadCache.set(thread.workspaceId, unreadIds) - } - - return { thread, isUnread: unreadIds.has(thread.id) } + await runThreadReadStateMutation(refs, options, { + verb: 'read', + plan: async (_client, threadId) => ({ + scope: '', + isUnchanged: (state) => state.lastReadObjIndex === null, + status: () => ({ id: threadId, isRead: true }), + apply: async (client, state) => { + await client.threads.markRead({ + id: threadId, + objIndex: getLatestObjIndex(state.thread), + }) + return null + }, + }), + }) } function getLatestObjIndex(thread: Thread): number { @@ -135,28 +37,3 @@ function getLatestObjIndex(thread: Thread): number { .map((value) => Math.max(value, 0)), ) } - -function threadLabel(thread: Thread): string { - return `${thread.title} (${thread.id})` -} - -function printSummary(statuses: TextStatus[]): void { - const summary = [ - summarizeStatus(statuses, 'changed'), - summarizeStatus(statuses, 'unchanged'), - summarizeStatus(statuses, 'preview'), - ].filter(Boolean) - - console.log('') - console.log(`Summary: ${summary.join(', ')}`) -} - -function summarizeStatus(statuses: TextStatus[], status: TextStatus): string | null { - const count = statuses.filter((value) => value === status).length - if (count === 0) { - return null - } - - const noun = status === 'preview' ? pluralize(count, 'preview') : pluralize(count, 'thread') - return status === 'preview' ? `${count} ${noun}` : `${count} ${status} ${noun}` -} diff --git a/src/commands/thread/thread.test.ts b/src/commands/thread/thread.test.ts index 9b81f65..fb65919 100644 --- a/src/commands/thread/thread.test.ts +++ b/src/commands/thread/thread.test.ts @@ -122,6 +122,12 @@ function createClient({ markRead: vi.fn(async ({ id }: { id: string; objIndex: number }) => { unreadState = unreadState.filter((unread) => unread.threadId !== id) }), + markUnread: vi.fn(async ({ id, objIndex }: { id: string; objIndex: number }) => { + unreadState = [ + ...unreadState.filter((unread) => unread.threadId !== id), + { threadId: id, channelId: 'CH100', objIndex, directMention: false }, + ] + }), muteThread: vi.fn(async (_args: { id: string; minutes: number }) => ({ ...thread, mutedUntil: new Date(Date.now() + _args.minutes * 60000), @@ -1273,6 +1279,32 @@ describe('thread read', () => { expect(jsonOutput).toEqual([{ id: '500', isRead: true }]) }) + it('emits the planned statuses with --json --dry-run', async () => { + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'mark-read', + '500', + '--json', + '--dry-run', + ]) + + expect(client.threads.markRead).not.toHaveBeenCalled() + const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(jsonOutput).toEqual([{ id: '500', isRead: true, dryRun: true }]) + }) + it('runs validation in dry-run mode', async () => { const client = createClient() apiMocks.getCommsClient.mockResolvedValue(client) @@ -1771,7 +1803,7 @@ describe('thread done', () => { await program.parseAsync(['node', 'tdc', 'thread', 'done', '500']) - expect(consoleSpy).toHaveBeenCalledWith('Would archive: Test Thread') + expect(consoleSpy).toHaveBeenCalledWith('Would archive: Test Thread (500)') expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') expect(client.inbox.archiveThread).not.toHaveBeenCalled() }) @@ -1815,6 +1847,19 @@ describe('thread done', () => { expect(client.inbox.archiveThread).not.toHaveBeenCalled() }) + it('flags an already archived thread in dry run', async () => { + const client = createClient({ thread: { ...createThreadFixture(500), isArchived: true } }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'done', '500', '--dry-run']) + + expect(consoleSpy).toHaveBeenCalledWith(' Status: already archived') + expect(client.inbox.archiveThread).not.toHaveBeenCalled() + }) + it('runs validation in dry-run mode', async () => { const client = createClient() apiMocks.getCommsClient.mockResolvedValue(client) @@ -1829,6 +1874,433 @@ describe('thread done', () => { }) }) +describe('thread undone', () => { + beforeEach(() => { + clearWorkspaceUserCache() + vi.clearAllMocks() + }) + + it('unarchives a thread with --yes', async () => { + const client = createClient({ thread: { ...createThreadFixture(500), isArchived: true } }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'undone', '500', '--yes']) + + expect(client.inbox.unarchiveThread).toHaveBeenCalledWith('500') + expect(client.inbox.archiveThread).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith('Thread 500 unarchived.') + }) + + it('skips the write when the thread is already in the inbox', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'undone', '500', '--yes']) + + expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith('Thread 500 unarchived (already in target state).') + }) + + it('prompts for confirmation without --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'undone', '500']) + + expect(consoleSpy).toHaveBeenCalledWith('Would unarchive: Test Thread (500)') + expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') + expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() + }) + + it('outputs JSON with --json --yes', async () => { + const client = createClient({ thread: { ...createThreadFixture(500), isArchived: true } }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'undone', '500', '--json', '--yes']) + + expect(client.inbox.unarchiveThread).toHaveBeenCalledWith('500') + const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(jsonOutput).toEqual({ id: '500', isArchived: false }) + }) + + it('errors when --json is used without --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'thread', 'undone', '500', '--json']), + ).rejects.toHaveProperty('code', 'MISSING_YES_FLAG') + + expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() + }) + + it('shows dry run output for an archived thread without a status line', async () => { + const client = createClient({ thread: { ...createThreadFixture(500), isArchived: true } }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'undone', '500', '--dry-run']) + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would unarchive thread')) + expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') + expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Status:')) + expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() + }) + + it('shows dry run output and flags a thread already in the inbox', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'undone', '500', '--dry-run']) + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would unarchive thread')) + expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') + expect(consoleSpy).toHaveBeenCalledWith(' Status: already in inbox') + expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() + }) + + it('runs validation in dry-run mode', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + client.threads.getThread.mockRejectedValueOnce(new Error('thread not found')) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'thread', 'undone', '500', '--dry-run']), + ).rejects.toThrow('thread not found') + expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() + }) +}) + +describe('thread mark-unread', () => { + beforeEach(() => { + clearWorkspaceUserCache() + vi.clearAllMocks() + vi.mocked(readStdinToEnd).mockResolvedValue('') + }) + + it('marks a read thread unread from the start without requiring --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500']) + + expect(client.threads.markUnread).toHaveBeenCalledWith({ id: '500', objIndex: -1 }) + expect(consoleSpy).toHaveBeenCalledWith('Thread Test Thread (500) marked unread.') + }) + + it('marks unread from a comment by passing the previous object index', async () => { + const client = createClient({ comments: [createComment(10, 0), createComment(11, 1)] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500', '--from', '11']) + + expect(client.comments.getComment).toHaveBeenCalledWith('11') + expect(client.threads.markUnread).toHaveBeenCalledWith({ id: '500', objIndex: 0 }) + expect(consoleSpy).toHaveBeenCalledWith( + 'Thread Test Thread (500) marked unread from comment 11.', + ) + }) + + it('rejects a --from comment that belongs to another thread', async () => { + const client = createClient({ + comments: [{ ...createComment(11, 1), threadId: '999' }], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500', '--from', '11']), + ).rejects.toHaveProperty('code', 'INVALID_REF') + expect(client.threads.markUnread).not.toHaveBeenCalled() + // The comment is validated before the workspace-wide unread lookup. + expect(client.threads.getUnread).not.toHaveBeenCalled() + }) + + it('rejects a --from comment with no object index', async () => { + const client = createClient({ + comments: [{ ...createComment(11, 1), objIndex: undefined as unknown as number }], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500', '--from', '11']), + ).rejects.toHaveProperty('code', 'INVALID_REF') + expect(client.threads.markUnread).not.toHaveBeenCalled() + }) + + it('rejects --from with more than one thread ref', async () => { + const client = createClient({ comments: [createComment(11, 1)] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'mark-unread', + '500', + '501', + '--from', + '11', + ]), + ).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS') + expect(client.threads.markUnread).not.toHaveBeenCalled() + }) + + it('rejects --from when stdin adds a second thread ref', async () => { + vi.mocked(readStdinToEnd).mockResolvedValueOnce('501\n') + const client = createClient({ comments: [createComment(11, 1)] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'mark-unread', + '500', + '--from', + '11', + '--yes', + ]), + ).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS') + expect(client.threads.markUnread).not.toHaveBeenCalled() + }) + + it.each([ + ['at the target', 0], + ['before the target', -1], + ])('leaves a thread already unread %s unchanged', async (_case, lastReadObjIndex) => { + const client = createClient({ + comments: [createComment(11, 1)], + unreadThreads: [ + { + threadId: '500', + channelId: 'CH100', + objIndex: lastReadObjIndex, + directMention: false, + }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500', '--from', '11']) + + expect(client.threads.markUnread).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith( + 'Thread Test Thread (500) is already unread from comment 11.', + ) + }) + + it('reports the current read position in JSON when nothing changes', async () => { + const client = createClient({ + comments: [createComment(12, 2)], + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 0, directMention: false }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'mark-unread', + '500', + '--from', + '12', + '--json', + ]) + + expect(client.threads.markUnread).not.toHaveBeenCalled() + const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(jsonOutput).toEqual([{ id: '500', isRead: false, lastReadObjIndex: 0 }]) + }) + + it('still moves an unread thread further back when the target is earlier', async () => { + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500']) + + expect(client.threads.markUnread).toHaveBeenCalledWith({ id: '500', objIndex: -1 }) + }) + + it('shows dry run output', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500', '--dry-run']) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Dry run: would mark unread thread Test Thread (500).', + ) + expect(client.threads.markUnread).not.toHaveBeenCalled() + }) + + it('outputs JSON with --json', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500', '--json']) + + const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(jsonOutput).toEqual([{ id: '500', isRead: false, lastReadObjIndex: -1 }]) + }) + + it('previews bulk refs from stdin and asks for --yes', async () => { + vi.mocked(readStdinToEnd).mockResolvedValueOnce('# refs\n500\n501\n') + const originalIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }) + + try { + const client = createClient() + client.threads.getThread.mockImplementation(async (id: string) => + createThreadFixture(id), + ) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-unread']) + + expect(readStdinToEnd).toHaveBeenCalled() + expect(client.threads.markUnread).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith('Would mark unread thread Test Thread (500).') + expect(consoleSpy).toHaveBeenCalledWith('Would mark unread thread Test Thread (501).') + expect(consoleSpy).toHaveBeenCalledWith('Summary: 2 previews') + expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }) + } + }) + + it('marks bulk refs unread with --yes', async () => { + const client = createClient() + client.threads.getThread.mockImplementation(async (id: string) => createThreadFixture(id)) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500', '501', '--yes']) + + expect(client.threads.markUnread).toHaveBeenCalledWith({ id: '500', objIndex: -1 }) + expect(client.threads.markUnread).toHaveBeenCalledWith({ id: '501', objIndex: -1 }) + expect(client.threads.markUnread).toHaveBeenCalledTimes(2) + expect(consoleSpy).toHaveBeenCalledWith('Thread Test Thread (500) marked unread.') + expect(consoleSpy).toHaveBeenCalledWith('Thread Test Thread (501) marked unread.') + expect(consoleSpy).toHaveBeenCalledWith('Summary: 2 changed threads') + }) + + it('emits the planned statuses with --json --dry-run', async () => { + const client = createClient() + client.threads.getThread.mockImplementation(async (id: string) => createThreadFixture(id)) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'mark-unread', + '500', + '501', + '--json', + '--dry-run', + ]) + + expect(client.threads.markUnread).not.toHaveBeenCalled() + const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(jsonOutput).toEqual([ + { id: '500', isRead: false, lastReadObjIndex: -1, dryRun: true }, + { id: '501', isRead: false, lastReadObjIndex: -1, dryRun: true }, + ]) + }) + + it('errors when --json is used for bulk refs without --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500', '501', '--json']), + ).rejects.toHaveProperty('code', 'MISSING_YES_FLAG') + expect(client.threads.markUnread).not.toHaveBeenCalled() + }) + + it('surfaces markUnread failures through the shared error path', async () => { + const client = createClient() + client.threads.markUnread.mockRejectedValueOnce(new Error('mark failed')) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'thread', 'mark-unread', '500']), + ).rejects.toThrow('mark failed') + }) +}) + describe('thread reply --file', () => { const files = useFileFixtures('tdc-reply-', 'diagram.png', 'report.pdf') diff --git a/src/commands/thread/unread.ts b/src/commands/thread/unread.ts new file mode 100644 index 0000000..cb42d22 --- /dev/null +++ b/src/commands/thread/unread.ts @@ -0,0 +1,87 @@ +import type { CommsApi } from '@doist/comms-sdk' +import { CliError } from '../../lib/errors.js' +import type { MutationOptions } from '../../lib/options.js' +import { resolveCommentId } from '../../lib/refs.js' +import { runThreadReadStateMutation } from './helpers.js' + +export type MarkThreadUnreadOptions = MutationOptions & { from?: string } + +type MarkUnreadStatus = { + id: string + isRead: false + /** Object index of the last comment still read; `-1` when the whole thread is unread. */ + lastReadObjIndex: number +} + +/** The API marks the thread body and every comment unread from `-1`. */ +const WHOLE_THREAD = -1 + +export async function markThreadUnread( + refs: string[], + options: MarkThreadUnreadOptions, +): Promise { + const from = options.from + + await runThreadReadStateMutation(refs, options, { + verb: 'unread', + validateRefs: (rawRefs) => { + if (from !== undefined && rawRefs.length > 1) { + throw new CliError( + 'CONFLICTING_OPTIONS', + '--from applies to a single thread; pass one thread ref when using it.', + ) + } + }, + plan: async (client, threadId) => { + const target = + from === undefined + ? WHOLE_THREAD + : await resolveFromObjIndex(client, threadId, from) + return { + scope: from === undefined ? '' : ` from comment ${from}`, + // Already unread at or before the target: nothing to move. + isUnchanged: (state) => + state.lastReadObjIndex !== null && state.lastReadObjIndex <= target, + status: (state, outcome) => ({ + id: threadId, + isRead: false, + lastReadObjIndex: + outcome === 'unchanged' ? (state.lastReadObjIndex ?? target) : target, + }), + apply: async (client) => { + await client.threads.markUnread({ id: threadId, objIndex: target }) + return target + }, + } + }, + }) +} + +/** + * Turns a comment ref into the `objIndex` to hand `markUnread`: the API takes + * the last comment that stays READ, so a comment at index N becomes the first + * unread one when the thread is marked unread from N - 1. + */ +async function resolveFromObjIndex( + client: CommsApi, + threadId: string, + commentRef: string, +): Promise { + const commentId = resolveCommentId(commentRef) + const comment = await client.comments.getComment(commentId) + + if (comment.threadId !== threadId) { + throw new CliError( + 'INVALID_REF', + `Comment ${commentId} belongs to thread ${comment.threadId}, not ${threadId}.`, + ) + } + if (typeof comment.objIndex !== 'number') { + throw new CliError( + 'INVALID_REF', + `Comment ${commentId} has no object index; cannot mark unread from it.`, + ) + } + + return comment.objIndex - 1 +} diff --git a/src/lib/api.ts b/src/lib/api.ts index c66aa71..c2aa04d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -36,6 +36,7 @@ const API_SPINNER_MESSAGES: Record "content" --file ./a.png # Attach a file (repeatable; co tdc thread done # Preview thread archive (requires --yes to execute) tdc thread done --yes # Archive thread (mark done) tdc thread done --yes --json # Archive and return status as JSON +tdc thread undone --yes # Unarchive thread (move it back to your inbox); inverse of done +tdc thread undone --yes --json # Unarchive and return status as JSON tdc thread mark-read # Mark a thread read tdc thread mark-read --yes # Mark multiple threads read printf "id:CbT8n2Kp4Qx6Rz9Lm3Va\\nid:CbT9m4Qr7Vz2Nx8Lp5Sa\\n" | tdc thread mark-read --dry-run # Preview bulk mark-read from stdin +tdc thread mark-unread # Mark a whole thread unread; inverse of mark-read +tdc thread mark-unread --from # Mark unread from that comment onward (single thread only) +tdc thread mark-unread --yes # Mark multiple threads unread (also accepts refs on stdin) tdc thread mute # Mute thread for 60 minutes (default) tdc thread mute --minutes 480 # Mute for custom duration tdc thread mute --json # Mute and return { id, mutedUntil } as JSON @@ -193,6 +198,8 @@ tdc conversation reply "content" --file ./a.png # Attach a file (repeatab tdc conversation done # Preview conversation archive (requires --yes to execute) tdc conversation done --yes # Archive conversation tdc conversation done --yes --json # Archive and return status as JSON +tdc conversation undone --yes # Unarchive conversation; inverse of done +tdc conversation undone --yes --json # Unarchive and return status as JSON tdc conversation mute # Mute conversation for 60 minutes (default) tdc conversation mute --minutes 480 # Mute for custom duration tdc conversation mute --json # Mute and return { id, mutedUntil } as JSON @@ -450,7 +457,7 @@ echo "Quick reply" | tdc conversation reply If no content argument is provided and no stdin is piped, the CLI opens \`$EDITOR\` for interactive input. In non-TTY environments (e.g. when called by an agent or in a pipeline), the editor is automatically skipped and the command fails fast with an actionable error message. Use \`--non-interactive\` to force this behavior even in a TTY, or \`--interactive\` to override auto-detection. -\`tdc thread mark-read\` also accepts thread refs from stdin, one per line: +\`tdc thread mark-read\` and \`tdc thread mark-unread\` also accept thread refs from stdin, one per line: \`\`\`bash printf "id:CbT8n2Kp4Qx6Rz9Lm3Va\\nid:CbT9m4Qr7Vz2Nx8Lp5Sa\\n" | tdc thread mark-read --yes @@ -473,6 +480,7 @@ tdc inbox --unread --json tdc thread view --unread tdc thread reply "Thanks, I'll look into this." tdc thread done --yes +tdc thread undone --yes # Changed your mind: back to the inbox \`\`\` **Search and review:**