Skip to content

Commit 6fbd645

Browse files
feat!: parse Comms URLs with the SDK (#55)
* feat!: parse Comms URLs with the SDK The SDK holds the shared Comms route rules, so parseCommsUrl hands the URL to it rather than walking path segments here. Both the /a/{id}/ and bare /{id}/ prefixes, the staging and local hosts, and the inbox and saved thread routes are all covered by it. A bare workspace URL names no entity, so the SDK does not recognise it; that case is still read here, and a malformed route still falls through to workspace-only rather than being misrouted as a thread, comment, or conversation ref. The search fallback link now comes from getFullCommsURL too, rather than being the one hand-built URL left in the CLI. Test IDs were short placeholders that the shared rules reject, so the URL fixtures now use real base58-encoded UUIDv7 values. BREAKING CHANGE: a Comms URL whose entity ID is not a base58-encoded UUIDv7 is no longer recognised. Comms only issues IDs in that format, so this rejects malformed links rather than passing them to the API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: pin the non-base58 id contract Every parse case now uses a valid base58-encoded UUIDv7, so the contract this change introduces — a well-formed path whose id is not that shape names no entity and reads as workspace-only — could regress without a failure. Covers the channel, thread and conversation routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f3dc2a4 commit 6fbd645

11 files changed

Lines changed: 332 additions & 199 deletions

package-lock.json

Lines changed: 11 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
],
5353
"dependencies": {
5454
"@doist/cli-core": "0.26.2",
55-
"@doist/comms-sdk": "2.0.2",
55+
"@doist/comms-sdk": "3.0.0",
5656
"@pnpm/tabtab": "0.5.4",
5757
"chalk": "5.6.2",
5858
"commander": "14.0.3",

src/commands/channel/members.test.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const createProgram = () => createTestProgram(registerChannelCommand)
2828

2929
function createChannel(userIds: number[], overrides: Record<string, unknown> = {}) {
3030
return {
31-
id: 'CH1',
31+
id: 'CeRAj1WU3YFhsTejuePLW',
3232
name: 'General',
3333
public: true,
3434
workspaceId: 1,
@@ -100,7 +100,7 @@ describe('tdc channel members list (default)', () => {
100100
await program.parseAsync(['node', 'tdc', 'channel', 'members', 'General', '--json'])
101101

102102
const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string)
103-
expect(payload.id).toBe('CH1')
103+
expect(payload.id).toBe('CeRAj1WU3YFhsTejuePLW')
104104
expect(payload.members).toHaveLength(3)
105105
expect(payload.members[0]).toEqual({ id: 1, name: 'Alice', email: 'a@d.com' })
106106
expect(payload.groupsFullyInChannel).toEqual([
@@ -159,7 +159,7 @@ describe('tdc channel members add', () => {
159159
'alice',
160160
])
161161

162-
expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CH1', [3])
162+
expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [3])
163163
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n')
164164
expect(output).toContain('Added 1 user to "General" (now 3 members)')
165165
expect(output).toContain('Already members: 1')
@@ -184,7 +184,7 @@ describe('tdc channel members add', () => {
184184
'group:Frontend',
185185
])
186186

187-
expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CH1', [2, 3])
187+
expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [2, 3])
188188
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n')
189189
expect(output).toContain('Expanded group "Frontend"')
190190
})
@@ -230,7 +230,7 @@ describe('tdc channel members add', () => {
230230

231231
const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string)
232232
expect(payload).toEqual({
233-
id: 'CH1',
233+
id: 'CeRAj1WU3YFhsTejuePLW',
234234
memberCount: 3,
235235
added: [3],
236236
alreadyMembers: [1],
@@ -256,7 +256,7 @@ describe('tdc channel members remove', () => {
256256
'id:9',
257257
])
258258

259-
expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CH1', [3])
259+
expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [3])
260260
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n')
261261
expect(output).toContain('Removed 1 user from "General" (now 2 members)')
262262
expect(output).toContain('Not members: 9')
@@ -317,8 +317,8 @@ describe('tdc channel members set', () => {
317317
'--apply',
318318
])
319319

320-
expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CH1', [3])
321-
expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CH1', [2])
320+
expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [3])
321+
expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [2])
322322
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n')
323323
expect(output).toContain('Set "General": +1 / -1 (now 2 members)')
324324
})
@@ -343,7 +343,12 @@ describe('tdc channel members set', () => {
343343
])
344344

345345
const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string)
346-
expect(payload).toEqual({ id: 'CH1', memberCount: 2, added: [3], removed: [2] })
346+
expect(payload).toEqual({
347+
id: 'CeRAj1WU3YFhsTejuePLW',
348+
memberCount: 2,
349+
added: [3],
350+
removed: [2],
351+
})
347352
})
348353

349354
it('emits JSON (not text) on dry-run --json without --apply', async () => {
@@ -367,7 +372,7 @@ describe('tdc channel members set', () => {
367372
expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled()
368373
const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string)
369374
expect(payload).toEqual({
370-
id: 'CH1',
375+
id: 'CeRAj1WU3YFhsTejuePLW',
371376
dryRun: true,
372377
memberCount: 2,
373378
added: [3],
@@ -392,6 +397,6 @@ describe('tdc channel members set', () => {
392397
'--include-self',
393398
])
394399

395-
expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CH1', [1])
400+
expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [1])
396401
})
397402
})

src/commands/inbox.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ describe('inbox empty output (channel filter)', () => {
136136
vi.clearAllMocks()
137137
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
138138
const thread = {
139-
id: 'TH1',
139+
id: 'CeRAj1WU3YFhsVZGDyPr9',
140140
channelId: 'CH10',
141141
title: 't',
142142
posted: '2026-05-01T00:00:00Z',

src/commands/react.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,14 @@ describe('react refs', () => {
3737
'tdc',
3838
'react',
3939
'thread',
40-
'https://comms.todoist.com/a/1/ch/2/t/99',
40+
'https://comms.todoist.com/a/1/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9',
4141
'+1',
4242
])
4343

44-
expect(apiMocks.addReaction).toHaveBeenCalledWith({ threadId: '99', reaction: '👍' })
44+
expect(apiMocks.addReaction).toHaveBeenCalledWith({
45+
threadId: 'CeRAj1WU3YFhsVZGDyPr9',
46+
reaction: '👍',
47+
})
4548
})
4649

4750
it('accepts message URLs for unreact', async () => {
@@ -53,11 +56,14 @@ describe('react refs', () => {
5356
'tdc',
5457
'unreact',
5558
'message',
56-
'https://comms.todoist.com/a/1/msg/33/m/44',
59+
'https://comms.todoist.com/a/1/msg/CeRAj1WU3YFhsatbAs43L/m/CeRAj1WU3YFhsbp9GT1ir',
5760
'heart',
5861
])
5962

60-
expect(apiMocks.removeReaction).toHaveBeenCalledWith({ messageId: '44', reaction: '❤️' })
63+
expect(apiMocks.removeReaction).toHaveBeenCalledWith({
64+
messageId: 'CeRAj1WU3YFhsbp9GT1ir',
65+
reaction: '❤️',
66+
})
6167
})
6268

6369
it('outputs JSON for react --json', async () => {

src/commands/view.test.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ describe('tdc view <url> routing', () => {
4545
'node',
4646
'tdc',
4747
'view',
48-
'https://comms.todoist.com/a/1585/ch/100/t/200',
48+
'https://comms.todoist.com/a/1585/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9',
4949
]),
5050
).rejects.toThrow('ROUTED_TO_THREAD')
5151
})
@@ -57,22 +57,32 @@ describe('tdc view <url> routing', () => {
5757
'node',
5858
'tdc',
5959
'view',
60-
'https://comms.todoist.com/a/1585/ch/100/t/200/c/300',
60+
'https://comms.todoist.com/a/1585/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj',
6161
]),
6262
).rejects.toThrow('ROUTED_TO_THREAD')
6363
})
6464

6565
it('routes conversation URL to conversation view', async () => {
6666
const program = createProgram()
6767
await expect(
68-
program.parseAsync(['node', 'tdc', 'view', 'https://comms.todoist.com/a/1585/msg/400']),
68+
program.parseAsync([
69+
'node',
70+
'tdc',
71+
'view',
72+
'https://comms.todoist.com/a/1585/msg/CeRAj1WU3YFhsatbAs43L',
73+
]),
6974
).rejects.toThrow('ROUTED_TO_CONVERSATION')
7075
})
7176

7277
it('routes short conversation URL to conversation view', async () => {
7378
const program = createProgram()
7479
await expect(
75-
program.parseAsync(['node', 'tdc', 'view', 'https://comms.todoist.com/1585/msg/400']),
80+
program.parseAsync([
81+
'node',
82+
'tdc',
83+
'view',
84+
'https://comms.todoist.com/1585/msg/CeRAj1WU3YFhsatbAs43L',
85+
]),
7686
).rejects.toThrow('ROUTED_TO_CONVERSATION')
7787
})
7888

@@ -83,7 +93,7 @@ describe('tdc view <url> routing', () => {
8393
'node',
8494
'tdc',
8595
'view',
86-
'https://comms.todoist.com/a/1585/msg/400/m/500',
96+
'https://comms.todoist.com/a/1585/msg/CeRAj1WU3YFhsatbAs43L/m/CeRAj1WU3YFhsbp9GT1ir',
8797
]),
8898
).rejects.toThrow('ROUTED_TO_MSG')
8999
})
@@ -102,7 +112,7 @@ describe('tdc view <url> routing', () => {
102112
'node',
103113
'tdc',
104114
'view',
105-
'https://comms.todoist.com/20/inbox/t/TH1/msg/CV1',
115+
'https://comms.todoist.com/20/inbox/t/CeRAj1WU3YFhsVZGDyPr9/msg/CeRAj1WU3YFhsatbAs43L',
106116
]),
107117
).rejects.toThrow('Not a recognized Comms URL')
108118
})

src/index.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('CLI entrypoint', () => {
7373
'tdc',
7474
'conversation',
7575
'view',
76-
'https://comms.todoist.com/123/msg/ANON_MESSAGE_ID/',
76+
'https://comms.todoist.com/123/msg/CeRAj1WU3YFhsatbAs43L/',
7777
'--from',
7878
'2026-06-26',
7979
])
@@ -84,7 +84,7 @@ describe('CLI entrypoint', () => {
8484
'node',
8585
'tdc',
8686
'view',
87-
'https://comms.todoist.com/a/123/msg/456',
87+
'https://comms.todoist.com/a/123/msg/CeRAj1WU3YFhsatbAs43L',
8888
'--from',
8989
'2026-06-26',
9090
])

src/lib/public-channels.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,20 @@ describe('getPublicChannelIds', () => {
8484
it('returns only public channel IDs', async () => {
8585
mockGetCommsClient.mockImplementation(() =>
8686
makeMockChannels([
87-
{ id: 'CH1', public: true },
87+
{ id: 'CeRAj1WU3YFhsTejuePLW', public: true },
8888
{ id: 'CH2', public: false },
8989
{ id: 'CH3', public: true },
9090
]),
9191
)
9292

9393
const ids = await getPublicChannelIds(100)
94-
expect(ids).toEqual(new Set(['CH1', 'CH3']))
94+
expect(ids).toEqual(new Set(['CeRAj1WU3YFhsTejuePLW', 'CH3']))
9595
})
9696

9797
it('caches results per workspace', async () => {
98-
const getChannels = vi.fn().mockResolvedValue([{ id: 'CH1', public: true }])
98+
const getChannels = vi
99+
.fn()
100+
.mockResolvedValue([{ id: 'CeRAj1WU3YFhsTejuePLW', public: true }])
99101
mockGetCommsClient.mockResolvedValue({
100102
channels: { getChannels },
101103
} as unknown as Awaited<ReturnType<typeof getCommsClient>>)
@@ -107,7 +109,9 @@ describe('getPublicChannelIds', () => {
107109
})
108110

109111
it('fetches separately for different workspaces', async () => {
110-
const getChannels = vi.fn().mockResolvedValue([{ id: 'CH1', public: true }])
112+
const getChannels = vi
113+
.fn()
114+
.mockResolvedValue([{ id: 'CeRAj1WU3YFhsTejuePLW', public: true }])
111115
mockGetCommsClient.mockResolvedValue({
112116
channels: { getChannels },
113117
} as unknown as Awaited<ReturnType<typeof getCommsClient>>)

0 commit comments

Comments
 (0)