WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: rename repoAtUri for accuracy

* Repos are identified by their DID now, hence the name change
* Update tests accordingly
* Consolidate @atproto/identity usage into pds-resolver.ts by moving
resolveHandleToDid there; at-uri.ts re-exports it and no longer
imports @atproto/identity directly

Co-authored-by: Claude (claude-sonnet-4-6) <noreply@anthropic.com>

authored by

Ken-ichi Ueda
Claude (claude-sonnet-4-6)
and committed by
Tangled
(Jun 1, 2026, 9:07 PM +0300) 41fea43d da67dcd5

+156 -165
+1 -1
tests/index.test.ts
··· 27 27 }); 28 28 29 29 it('package.json should have correct name', () => { 30 - expect(packageJson.name).toBe('tangled-cli'); 30 + expect(packageJson.name).toBe('@markbennett/tang'); 31 31 }); 32 32 });
+20 -32
src/commands/issue.ts
··· 31 31 * Resolve issue number or rkey to full AT-URI 32 32 * @param input - User input: number ("1"), hash ("#1"), or rkey ("3mef...") 33 33 * @param client - API client 34 - * @param repoAtUri - Repository AT-URI 34 + * @param repoId - Repository identifier (repoDid or AT-URI fallback) 35 35 * @returns Object with full issue AT-URI and display identifier 36 36 */ 37 37 async function resolveIssueUri( 38 38 input: string, 39 39 client: TangledApiClient, 40 - repoAtUri: string 40 + repoId: string 41 41 ): Promise<{ uri: string; displayId: string }> { 42 42 // Strip # prefix if present 43 43 const normalized = input.startsWith('#') ? input.slice(1) : input; ··· 53 53 // Query all issues for this repo 54 54 const { issues } = await listIssues({ 55 55 client, 56 - repoAtUri, 56 + repoId, 57 57 limit: 100, // Adjust if needed for large repos 58 58 }); 59 59 ··· 122 122 } 123 123 124 124 // 3. Build repo AT-URI 125 - const repoAtUri = await resolveRepoId(context.owner, context.name); 125 + const repoId = await resolveRepoId(context.owner, context.name); 126 126 127 127 // 4. Resolve issue ID to URI 128 - const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 128 + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoId); 129 129 130 130 // 5. Fetch complete issue data (record, sequential number, state) 131 - const issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); 131 + const issueData = await getCompleteIssueData(client, issueUri, displayId, repoId); 132 132 133 133 // 6. Output result 134 134 if (options.json !== undefined) { ··· 195 195 } 196 196 197 197 // 4. Build repo AT-URI 198 - const repoAtUri = await resolveRepoId(context.owner, context.name); 198 + const repoId = await resolveRepoId(context.owner, context.name); 199 199 200 200 // 5. Resolve issue ID to URI 201 - const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 201 + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoId); 202 202 203 203 // 6. Handle body input 204 204 const body = await readBodyInput(options.body, options.bodyFile); ··· 218 218 // 9. Output result 219 219 if (options.json !== undefined) { 220 220 const [number, state] = await Promise.all([ 221 - resolveSequentialNumber(displayId, updatedIssue.uri, client, repoAtUri), 221 + resolveSequentialNumber(displayId, updatedIssue.uri, client, repoId), 222 222 getIssueState({ client, issueUri: updatedIssue.uri }), 223 223 ]); 224 224 const issueData: IssueData = { ··· 275 275 } 276 276 277 277 // 3. Build repo AT-URI 278 - const repoAtUri = await resolveRepoId(context.owner, context.name); 278 + const repoId = await resolveRepoId(context.owner, context.name); 279 279 280 280 // 4. Resolve issue ID to URI 281 - const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 281 + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoId); 282 282 283 283 // 5. Fetch complete issue data (state will be 'closed' after operation) 284 - const issueData = await getCompleteIssueData( 285 - client, 286 - issueUri, 287 - displayId, 288 - repoAtUri, 289 - 'closed' 290 - ); 284 + const issueData = await getCompleteIssueData(client, issueUri, displayId, repoId, 'closed'); 291 285 292 286 // 6. Close issue 293 287 await closeIssue({ client, issueUri }); ··· 332 326 } 333 327 334 328 // 3. Build repo AT-URI 335 - const repoAtUri = await resolveRepoId(context.owner, context.name); 329 + const repoId = await resolveRepoId(context.owner, context.name); 336 330 337 331 // 4. Resolve issue ID to URI 338 - const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 332 + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoId); 339 333 340 334 // 5. Fetch complete issue data (state will be 'open' after operation) 341 - const issueData = await getCompleteIssueData( 342 - client, 343 - issueUri, 344 - displayId, 345 - repoAtUri, 346 - 'open' 347 - ); 335 + const issueData = await getCompleteIssueData(client, issueUri, displayId, repoId, 'open'); 348 336 349 337 // 6. Reopen issue 350 338 await reopenIssue({ client, issueUri }); ··· 421 409 } 422 410 423 411 // 5. Build repo AT-URI 424 - const repoAtUri = await resolveRepoId(context.owner, context.name); 412 + const repoId = await resolveRepoId(context.owner, context.name); 425 413 426 414 // 6. Create issue (suppress progress message in JSON mode) 427 415 if (options.json === undefined) { ··· 429 417 } 430 418 const issue = await createIssue({ 431 419 client, 432 - repoAtUri, 420 + repoId, 433 421 title: validTitle, 434 422 body, 435 423 }); 436 424 437 425 // 7. Compute sequential number 438 - const { issues: allIssues } = await listIssues({ client, repoAtUri, limit: 100 }); 426 + const { issues: allIssues } = await listIssues({ client, repoId, limit: 100 }); 439 427 const sortedAll = allIssues.sort( 440 428 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 441 429 ); ··· 496 484 } 497 485 498 486 // 3. Build repo AT-URI 499 - const repoAtUri = await resolveRepoId(context.owner, context.name); 487 + const repoId = await resolveRepoId(context.owner, context.name); 500 488 501 489 // 4. Fetch issues 502 490 const limit = Number.parseInt(options.limit, 10); ··· 507 495 508 496 const { issues } = await listIssues({ 509 497 client, 510 - repoAtUri, 498 + repoId, 511 499 limit, 512 500 }); 513 501
+10 -16
src/lib/issues-api.ts
··· 32 32 */ 33 33 export interface CreateIssueParams { 34 34 client: TangledApiClient; 35 - repoAtUri: string; 35 + repoId: string; 36 36 title: string; 37 37 body?: string; 38 38 } ··· 42 42 */ 43 43 export interface ListIssuesParams { 44 44 client: TangledApiClient; 45 - repoAtUri: string; 45 + repoId: string; 46 46 limit?: number; 47 47 cursor?: string; 48 48 } ··· 115 115 * Create a new issue 116 116 */ 117 117 export async function createIssue(params: CreateIssueParams): Promise<IssueWithMetadata> { 118 - const { client, repoAtUri, title, body } = params; 118 + const { client, repoId, title, body } = params; 119 119 120 120 // Validate authentication 121 121 const session = await requireAuth(client); ··· 123 123 // Build issue record 124 124 const record: IssueRecord = { 125 125 $type: 'sh.tangled.repo.issue', 126 - repo: repoAtUri, 126 + repo: repoId, 127 127 title, 128 128 body, 129 129 createdAt: new Date().toISOString(), ··· 158 158 issues: IssueWithMetadata[]; 159 159 cursor?: string; 160 160 }> { 161 - const { client, repoAtUri, limit = 50, cursor } = params; 161 + const { client, repoId, limit = 50, cursor } = params; 162 162 163 163 // Validate authentication 164 164 await requireAuth(client); 165 165 166 166 try { 167 167 // Query constellation for all issues that reference this repo across all PDSs 168 - const backlinks = await getBacklinks( 169 - repoAtUri, 170 - 'sh.tangled.repo.issue', 171 - '.repo', 172 - limit, 173 - cursor 174 - ); 168 + const backlinks = await getBacklinks(repoId, 'sh.tangled.repo.issue', '.repo', limit, cursor); 175 169 176 170 // Fetch each issue record individually (constellation only gives us the AT-URI components) 177 171 const issuePromises = backlinks.records.map(async ({ did, collection, rkey }) => { ··· 387 381 displayId: string, 388 382 issueUri: string, 389 383 client: TangledApiClient, 390 - repoAtUri: string 384 + repoId: string 391 385 ): Promise<number | undefined> { 392 386 const match = displayId.match(/^#(\d+)$/); 393 387 if (match) return Number.parseInt(match[1], 10); 394 388 395 - const { issues } = await listIssues({ client, repoAtUri, limit: 100 }); 389 + const { issues } = await listIssues({ client, repoId, limit: 100 }); 396 390 const sorted = issues.sort( 397 391 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 398 392 ); ··· 424 418 client: TangledApiClient, 425 419 issueUri: string, 426 420 displayId: string, 427 - repoAtUri: string, 421 + repoId: string, 428 422 stateOverride?: 'open' | 'closed' 429 423 ): Promise<IssueData> { 430 424 const [issue, number] = await Promise.all([ 431 425 getIssue({ client, issueUri }), 432 - resolveSequentialNumber(displayId, issueUri, client, repoAtUri), 426 + resolveSequentialNumber(displayId, issueUri, client, repoId), 433 427 ]); 434 428 const state = stateOverride ?? (await getIssueState({ client, issueUri })); 435 429 return {
+5 -36
src/utils/at-uri.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 - import { IdResolver } from '@atproto/identity'; 3 - import { resolvePdsFromIdentifier } from './pds-resolver.js'; 2 + import { resolveHandleToDid, resolvePdsFromIdentifier } from './pds-resolver.js'; 3 + 4 + export { resolveHandleToDid }; 4 5 5 6 /** 6 7 * Parse an AT-URI into its components ··· 30 31 } 31 32 32 33 /** 33 - * Resolve a handle to a DID using AT Protocol identity resolution. 34 - * Uses @atproto/identity directly rather than routing through the authenticated 35 - * agent, so the result is independent of which PDS the caller is logged into. 36 - * 37 - * @param handle - Handle string (e.g., "mark.bsky.social" or "@mark.bsky.social") 38 - * @returns DID string (e.g., "did:plc:abc123") 39 - * @throws Error if handle cannot be resolved 40 - */ 41 - export async function resolveHandleToDid(handle: string): Promise<string> { 42 - // Strip leading @ if present 43 - const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; 44 - 45 - try { 46 - const resolver = new IdResolver(); 47 - const did = await resolver.handle.resolve(cleanHandle); 48 - 49 - if (!did) { 50 - throw new Error(`No DID found for handle: ${cleanHandle}`); 51 - } 52 - 53 - return did; 54 - } catch (error) { 55 - if (error instanceof Error && error.message.startsWith('No DID found')) { 56 - throw error; 57 - } 58 - if (error instanceof Error) { 59 - throw new Error(`Failed to resolve handle '${cleanHandle}': ${error.message}`); 60 - } 61 - throw new Error(`Failed to resolve handle '${cleanHandle}': Unknown error`); 62 - } 63 - } 64 - 65 - /** 66 34 * Resolve a repository identifier to its repo DID (the identifier used in issue records). 67 35 * Resolves the owner's PDS independently of the caller's authenticated session, 68 36 * so cross-PDS lookups (e.g., a tngl.sh user looking up a bsky.social repo) work. ··· 87 55 const ownerAgent = new AtpAgent({ service: ownerPds }); 88 56 89 57 // Query for sh.tangled.repo records 58 + // TODO: does not paginate; will silently fail to find a repo for users with >100 repos. 90 59 const response = await ownerAgent.com.atproto.repo.listRecords({ 91 60 repo: did, 92 61 collection: 'sh.tangled.repo', 93 - limit: 100, // Reasonable limit for most users 62 + limit: 100, 94 63 }); 95 64 96 65 // Find the record matching the repo name.
+37
src/utils/pds-resolver.ts
··· 1 1 import { ensureAtpDocument, IdResolver } from '@atproto/identity'; 2 2 3 + /** 4 + * Resolve a handle to a DID using AT Protocol identity resolution. 5 + * Uses @atproto/identity directly rather than routing through the authenticated 6 + * agent, so the result is independent of which PDS the caller is logged into. 7 + * 8 + * @param handle - Handle string (e.g., "mark.bsky.social" or "@mark.bsky.social") 9 + * @returns DID string (e.g., "did:plc:abc123") 10 + * @throws Error if handle cannot be resolved 11 + */ 12 + export async function resolveHandleToDid(handle: string): Promise<string> { 13 + // Strip leading @ if present 14 + const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; 15 + 16 + try { 17 + const resolver = new IdResolver(); 18 + const did = await resolver.handle.resolve(cleanHandle); 19 + 20 + if (!did) { 21 + throw new Error(`No DID found for handle: ${cleanHandle}`); 22 + } 23 + 24 + return did; 25 + } catch (error) { 26 + if (error instanceof Error && error.message.startsWith('No DID found')) { 27 + throw error; 28 + } 29 + if (error instanceof Error) { 30 + throw new Error(`Failed to resolve handle '${cleanHandle}': ${error.message}`); 31 + } 32 + throw new Error(`Failed to resolve handle '${cleanHandle}': Unknown error`); 33 + } 34 + } 35 + 3 36 export async function resolvePdsFromIdentifier(identifier: string): Promise<string> { 4 37 const resolver = new IdResolver(); 5 38 ··· 14 47 did = resolved; 15 48 } 16 49 50 + // resolveNoCheck fetches the DID document without verifying its signature. 51 + // Acceptable for a CLI that trusts the DNS/HTTPS infrastructure used for 52 + // handle resolution; avoids the overhead of full cryptographic verification. 17 53 const didDoc = await resolver.did.resolveNoCheck(did); 54 + // Cast needed because resolveNoCheck returns a broader type than ensureAtpDocument expects. 18 55 const atpData = ensureAtpDocument(didDoc as Parameters<typeof ensureAtpDocument>[0]); 19 56 return atpData.pds; 20 57 }
+22 -24
tests/commands/issue.test.ts
··· 48 48 protocol: 'ssh', 49 49 }); 50 50 51 - // Mock AT-URI builder 52 - vi.mocked(atUri.resolveRepoId).mockResolvedValue( 53 - 'at://did:plc:abc123/sh.tangled.repo/test-repo' 54 - ); 51 + // Mock repo ID resolver 52 + vi.mocked(atUri.resolveRepoId).mockResolvedValue('did:plc:repodid111'); 55 53 56 54 // Mock body input 57 55 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); ··· 86 84 87 85 expect(issuesApi.createIssue).toHaveBeenCalledWith({ 88 86 client: mockClient, 89 - repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 87 + repoId: 'did:plc:repodid111', 90 88 title: 'Test Issue', 91 89 body: 'Test body', 92 90 }); ··· 126 124 expect(bodyInput.readBodyInput).toHaveBeenCalledWith(undefined, '/tmp/body.txt'); 127 125 expect(issuesApi.createIssue).toHaveBeenCalledWith({ 128 126 client: mockClient, 129 - repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 127 + repoId: 'did:plc:repodid111', 130 128 title: 'Test Issue', 131 129 body: 'Body from file', 132 130 }); ··· 154 152 155 153 expect(issuesApi.createIssue).toHaveBeenCalledWith({ 156 154 client: mockClient, 157 - repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 155 + repoId: 'did:plc:repodid111', 158 156 title: 'Test Issue', 159 157 body: undefined, 160 158 }); ··· 341 339 }); 342 340 343 341 // Mock AT-URI builder 344 - vi.mocked(atUri.resolveRepoId).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 342 + vi.mocked(atUri.resolveRepoId).mockResolvedValue('did:plc:repodid111'); 345 343 }); 346 344 347 345 afterEach(() => { ··· 353 351 const mockIssues: IssueWithMetadata[] = [ 354 352 { 355 353 $type: 'sh.tangled.repo.issue', 356 - repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 354 + repo: 'did:plc:repodid111', 357 355 title: 'First Issue', 358 356 createdAt: new Date('2024-01-01').toISOString(), 359 357 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', ··· 362 360 }, 363 361 { 364 362 $type: 'sh.tangled.repo.issue', 365 - repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 363 + repo: 'did:plc:repodid111', 366 364 title: 'Second Issue', 367 365 createdAt: new Date('2024-01-02').toISOString(), 368 366 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', ··· 382 380 383 381 expect(issuesApi.listIssues).toHaveBeenCalledWith({ 384 382 client: mockClient, 385 - repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 383 + repoId: 'did:plc:repodid111', 386 384 limit: 50, 387 385 }); 388 386 ··· 402 400 403 401 expect(issuesApi.listIssues).toHaveBeenCalledWith({ 404 402 client: mockClient, 405 - repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 403 + repoId: 'did:plc:repodid111', 406 404 limit: 25, 407 405 }); 408 406 }); ··· 503 501 const mockIssues: IssueWithMetadata[] = [ 504 502 { 505 503 $type: 'sh.tangled.repo.issue', 506 - repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 504 + repo: 'did:plc:repodid111', 507 505 title: 'First Issue', 508 506 body: 'First body', 509 507 createdAt: new Date('2024-01-01').toISOString(), ··· 513 511 }, 514 512 { 515 513 $type: 'sh.tangled.repo.issue', 516 - repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 514 + repo: 'did:plc:repodid111', 517 515 title: 'Second Issue', 518 516 createdAt: new Date('2024-01-02').toISOString(), 519 517 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', ··· 574 572 575 573 const mockIssue: IssueWithMetadata = { 576 574 $type: 'sh.tangled.repo.issue', 577 - repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 575 + repo: 'did:plc:repodid111', 578 576 title: 'Test Issue', 579 577 body: 'Issue body', 580 578 createdAt: new Date('2024-01-01').toISOString(), ··· 604 602 protocol: 'ssh', 605 603 }); 606 604 607 - vi.mocked(atUri.resolveRepoId).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 605 + vi.mocked(atUri.resolveRepoId).mockResolvedValue('did:plc:repodid111'); 608 606 609 607 vi.mocked(authHelpers.requireAuth).mockResolvedValue({ 610 608 did: 'did:plc:abc123', ··· 639 637 mockClient, 640 638 mockIssue.uri, 641 639 '#1', 642 - 'at://did:plc:abc123/sh.tangled.repo/xyz789' 640 + 'did:plc:repodid111' 643 641 ); 644 642 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue #1 [OPEN]'); 645 643 expect(consoleLogSpy).toHaveBeenCalledWith('Title: Test Issue'); ··· 666 664 mockClient, 667 665 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 668 666 'issue1', 669 - 'at://did:plc:abc123/sh.tangled.repo/xyz789' 667 + 'did:plc:repodid111' 670 668 ); 671 669 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue issue1 [CLOSED]'); 672 670 }); ··· 801 799 802 800 const mockIssue: IssueWithMetadata = { 803 801 $type: 'sh.tangled.repo.issue', 804 - repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 802 + repo: 'did:plc:repodid111', 805 803 title: 'Original Title', 806 804 createdAt: new Date('2024-01-01').toISOString(), 807 805 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', ··· 830 828 protocol: 'ssh', 831 829 }); 832 830 833 - vi.mocked(atUri.resolveRepoId).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 831 + vi.mocked(atUri.resolveRepoId).mockResolvedValue('did:plc:repodid111'); 834 832 835 833 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 836 834 vi.mocked(authHelpers.requireAuth).mockResolvedValue({ ··· 975 973 976 974 const mockIssue: IssueWithMetadata = { 977 975 $type: 'sh.tangled.repo.issue', 978 - repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 976 + repo: 'did:plc:repodid111', 979 977 title: 'Test Issue', 980 978 createdAt: new Date('2024-01-01').toISOString(), 981 979 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', ··· 1004 1002 protocol: 'ssh', 1005 1003 }); 1006 1004 1007 - vi.mocked(atUri.resolveRepoId).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1005 + vi.mocked(atUri.resolveRepoId).mockResolvedValue('did:plc:repodid111'); 1008 1006 vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1009 1007 number: 1, 1010 1008 title: mockIssue.title, ··· 1092 1090 1093 1091 const mockIssue: IssueWithMetadata = { 1094 1092 $type: 'sh.tangled.repo.issue', 1095 - repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 1093 + repo: 'did:plc:repodid111', 1096 1094 title: 'Test Issue', 1097 1095 createdAt: new Date('2024-01-01').toISOString(), 1098 1096 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', ··· 1121 1119 protocol: 'ssh', 1122 1120 }); 1123 1121 1124 - vi.mocked(atUri.resolveRepoId).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1122 + vi.mocked(atUri.resolveRepoId).mockResolvedValue('did:plc:repodid111'); 1125 1123 vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1126 1124 number: 1, 1127 1125 title: mockIssue.title,
+8 -8
tests/lib/issues-api.test.ts
··· 67 67 68 68 const result = await createIssue({ 69 69 client: mockClient, 70 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 70 + repoId: 'at://did:plc:owner/sh.tangled.repo/my-repo', 71 71 title: 'Bug: Login fails', 72 72 body: 'Detailed description of the bug', 73 73 }); ··· 114 114 115 115 const result = await createIssue({ 116 116 client: mockClient, 117 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 117 + repoId: 'at://did:plc:owner/sh.tangled.repo/my-repo', 118 118 title: 'Simple issue', 119 119 }); 120 120 ··· 128 128 await expect( 129 129 createIssue({ 130 130 client: mockClient, 131 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 131 + repoId: 'at://did:plc:owner/sh.tangled.repo/my-repo', 132 132 title: 'Test', 133 133 }) 134 134 ).rejects.toThrow('Must be authenticated'); ··· 150 150 await expect( 151 151 createIssue({ 152 152 client: mockClient, 153 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 153 + repoId: 'at://did:plc:owner/sh.tangled.repo/my-repo', 154 154 title: 'Test', 155 155 }) 156 156 ).rejects.toThrow('Failed to create issue: API error'); ··· 208 208 209 209 const result = await listIssues({ 210 210 client: mockClient, 211 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 211 + repoId: 'at://did:plc:owner/sh.tangled.repo/my-repo', 212 212 }); 213 213 214 214 expect(result.issues).toHaveLength(2); ··· 238 238 239 239 const result = await listIssues({ 240 240 client: mockClient, 241 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 241 + repoId: 'at://did:plc:owner/sh.tangled.repo/my-repo', 242 242 }); 243 243 244 244 expect(result.issues).toEqual([]); ··· 249 249 250 250 const result = await listIssues({ 251 251 client: mockClient, 252 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 252 + repoId: 'at://did:plc:owner/sh.tangled.repo/my-repo', 253 253 }); 254 254 255 255 expect(result.cursor).toBe('nextpage'); ··· 261 261 await expect( 262 262 listIssues({ 263 263 client: mockClient, 264 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 264 + repoId: 'at://did:plc:owner/sh.tangled.repo/my-repo', 265 265 }) 266 266 ).rejects.toThrow('Must be authenticated'); 267 267 });
+5 -47
tests/utils/at-uri.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 - import { parseAtUri, resolveHandleToDid, resolveRepoId } from '../../src/utils/at-uri.js'; 2 + import { parseAtUri, resolveRepoId } from '../../src/utils/at-uri.js'; 3 3 4 4 const { mockHandleResolve, mockListRecords, mockResolvePds } = vi.hoisted(() => ({ 5 5 mockHandleResolve: vi.fn(), ··· 7 7 mockResolvePds: vi.fn().mockResolvedValue('https://pds.example.com'), 8 8 })); 9 9 10 - vi.mock('@atproto/identity', () => ({ 11 - IdResolver: vi.fn().mockImplementation(() => ({ 12 - handle: { resolve: mockHandleResolve }, 13 - })), 14 - })); 15 - 16 10 vi.mock('../../src/utils/pds-resolver.js', () => ({ 17 11 resolvePdsFromIdentifier: mockResolvePds, 12 + resolveHandleToDid: mockHandleResolve, 18 13 })); 19 14 20 15 vi.mock('@atproto/api', () => ({ ··· 72 67 collection: 'collection', 73 68 rkey: 'rkey', 74 69 }); 75 - }); 76 - }); 77 - 78 - describe('resolveHandleToDid', () => { 79 - beforeEach(() => { 80 - vi.clearAllMocks(); 81 - }); 82 - 83 - it('should resolve handle to DID', async () => { 84 - mockHandleResolve.mockResolvedValue('did:plc:abc123'); 85 - 86 - const result = await resolveHandleToDid('mark.bsky.social'); 87 - 88 - expect(result).toBe('did:plc:abc123'); 89 - expect(mockHandleResolve).toHaveBeenCalledWith('mark.bsky.social'); 90 - }); 91 - 92 - it('should strip leading @ from handle', async () => { 93 - mockHandleResolve.mockResolvedValue('did:plc:abc123'); 94 - 95 - await resolveHandleToDid('@mark.bsky.social'); 96 - 97 - expect(mockHandleResolve).toHaveBeenCalledWith('mark.bsky.social'); 98 - }); 99 - 100 - it('should throw error when handle resolves to nothing', async () => { 101 - mockHandleResolve.mockResolvedValue(undefined); 102 - 103 - await expect(resolveHandleToDid('nonexistent.bsky.social')).rejects.toThrow( 104 - 'No DID found for handle: nonexistent.bsky.social' 105 - ); 106 - }); 107 - 108 - it('should throw error on resolution failure', async () => { 109 - mockHandleResolve.mockRejectedValue(new Error('Network error')); 110 - 111 - await expect(resolveHandleToDid('mark.bsky.social')).rejects.toThrow( 112 - "Failed to resolve handle 'mark.bsky.social': Network error" 113 - ); 114 70 }); 115 71 }); 116 72 ··· 237 193 }); 238 194 239 195 it('should throw error when handle resolution fails', async () => { 240 - mockHandleResolve.mockRejectedValue(new Error('Resolution failed')); 196 + mockHandleResolve.mockRejectedValue( 197 + new Error("Failed to resolve handle 'mark.bsky.social': Resolution failed") 198 + ); 241 199 242 200 await expect(resolveRepoId('mark.bsky.social', 'my-repo')).rejects.toThrow( 243 201 "Failed to resolve handle 'mark.bsky.social': Resolution failed"
+48 -1
tests/utils/pds-resolver.test.ts
··· 1 1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 - import { resolvePdsFromIdentifier } from '../../src/utils/pds-resolver.js'; 2 + import { resolveHandleToDid, resolvePdsFromIdentifier } from '../../src/utils/pds-resolver.js'; 3 3 4 4 const PLC_DID = 'did:plc:abc123'; 5 5 const WEB_DID = 'did:web:example.com'; ··· 123 123 expect(resolveHandle).not.toHaveBeenCalled(); 124 124 expect(result).toBe(PDS_URL); 125 125 }); 126 + }); 127 + }); 128 + 129 + describe('resolveHandleToDid', () => { 130 + beforeEach(() => { 131 + vi.clearAllMocks(); 132 + }); 133 + 134 + afterEach(() => { 135 + vi.clearAllMocks(); 136 + }); 137 + 138 + it('should resolve handle to DID', async () => { 139 + const { resolveHandle } = await getMocks(); 140 + resolveHandle.mockResolvedValue(PLC_DID); 141 + 142 + const result = await resolveHandleToDid('mark.bsky.social'); 143 + 144 + expect(result).toBe(PLC_DID); 145 + expect(resolveHandle).toHaveBeenCalledWith('mark.bsky.social'); 146 + }); 147 + 148 + it('should strip leading @ from handle', async () => { 149 + const { resolveHandle } = await getMocks(); 150 + resolveHandle.mockResolvedValue(PLC_DID); 151 + 152 + await resolveHandleToDid('@mark.bsky.social'); 153 + 154 + expect(resolveHandle).toHaveBeenCalledWith('mark.bsky.social'); 155 + }); 156 + 157 + it('should throw error when handle resolves to nothing', async () => { 158 + const { resolveHandle } = await getMocks(); 159 + resolveHandle.mockResolvedValue(undefined); 160 + 161 + await expect(resolveHandleToDid('nonexistent.bsky.social')).rejects.toThrow( 162 + 'No DID found for handle: nonexistent.bsky.social' 163 + ); 164 + }); 165 + 166 + it('should throw error on resolution failure', async () => { 167 + const { resolveHandle } = await getMocks(); 168 + resolveHandle.mockRejectedValue(new Error('Network error')); 169 + 170 + await expect(resolveHandleToDid('mark.bsky.social')).rejects.toThrow( 171 + "Failed to resolve handle 'mark.bsky.social': Network error" 172 + ); 126 173 }); 127 174 });