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! :)

Implement issue list command

Add ability to list all issues for the current repository.

Features:
- Lists issues with rkey (issue number) and title
- Shows creation date with human-readable formatting
- Supports --limit option (1-100, default 50)
- Displays "No issues found" message when list is empty

New utilities:
- Add formatDate() in src/utils/formatting.ts
- Converts dates to human-readable format (today, yesterday, X days/weeks/months ago, or locale date)
- Comprehensive test coverage with 9 tests

Implementation:
- Add createListCommand() with limit validation
- Import formatDate from utils/formatting.ts
- Add 9 comprehensive command tests

Tests:
- List issues successfully
- Custom limit handling
- Empty list handling
- Authentication required
- Context required
- Invalid limit validation (too low/high/non-numeric)
- API error handling
- Date formatting tests (9 tests covering all ranges)

All 232 tests passing ✅

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

markbennett.ca c1c606b4 2c55d78c

verified
+369 -1
+68 -1
src/commands/issue.ts
··· 1 1 import { Command } from 'commander'; 2 2 import { createApiClient } from '../lib/api-client.js'; 3 3 import { getCurrentRepoContext } from '../lib/context.js'; 4 - import { createIssue } from '../lib/issues-api.js'; 4 + import { createIssue, listIssues } from '../lib/issues-api.js'; 5 5 import { buildRepoAtUri } from '../utils/at-uri.js'; 6 6 import { readBodyInput } from '../utils/body-input.js'; 7 + import { formatDate } from '../utils/formatting.js'; 7 8 import { validateIssueBody, validateIssueTitle } from '../utils/validation.js'; 8 9 9 10 /** ··· 22 23 issue.description('Manage issues in Tangled repositories'); 23 24 24 25 issue.addCommand(createCreateCommand()); 26 + issue.addCommand(createListCommand()); 25 27 26 28 return issue; 27 29 } ··· 87 89 } 88 90 }); 89 91 } 92 + 93 + /** 94 + * Issue list subcommand 95 + */ 96 + function createListCommand(): Command { 97 + return new Command('list') 98 + .description('List issues for the current repository') 99 + .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50') 100 + .action(async (options: { limit: string }) => { 101 + try { 102 + // 1. Validate auth 103 + const client = createApiClient(); 104 + if (!(await client.resumeSession())) { 105 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 106 + process.exit(1); 107 + } 108 + 109 + // 2. Get repo context 110 + const context = await getCurrentRepoContext(); 111 + if (!context) { 112 + console.error('✗ Not in a Tangled repository'); 113 + console.error('\nTo use this repository with Tangled, add a remote:'); 114 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 115 + process.exit(1); 116 + } 117 + 118 + // 3. Build repo AT-URI 119 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 120 + 121 + // 4. Fetch issues 122 + const limit = Number.parseInt(options.limit, 10); 123 + if (Number.isNaN(limit) || limit < 1 || limit > 100) { 124 + console.error('✗ Invalid limit. Must be between 1 and 100.'); 125 + process.exit(1); 126 + } 127 + 128 + const { issues } = await listIssues({ 129 + client, 130 + repoAtUri, 131 + limit, 132 + }); 133 + 134 + // 5. Display results 135 + if (issues.length === 0) { 136 + console.log('No issues found for this repository.'); 137 + return; 138 + } 139 + 140 + console.log(`\nFound ${issues.length} issue${issues.length === 1 ? '' : 's'}:\n`); 141 + 142 + for (const issue of issues) { 143 + const rkey = extractRkey(issue.uri); 144 + const date = formatDate(issue.createdAt); 145 + console.log(` #${rkey} ${issue.title}`); 146 + console.log(` Created ${date}`); 147 + console.log(); 148 + } 149 + } catch (error) { 150 + console.error( 151 + `✗ Failed to list issues: ${error instanceof Error ? error.message : 'Unknown error'}` 152 + ); 153 + process.exit(1); 154 + } 155 + }); 156 + }
+18
src/utils/formatting.ts
··· 1 + /** 2 + * Format a date for display with human-readable relative time 3 + * @param dateString - ISO date string 4 + * @returns Human-readable date string 5 + */ 6 + export function formatDate(dateString: string): string { 7 + const date = new Date(dateString); 8 + const now = new Date(); 9 + const diff = now.getTime() - date.getTime(); 10 + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); 11 + 12 + if (days === 0) return 'today'; 13 + if (days === 1) return 'yesterday'; 14 + if (days < 7) return `${days} days ago`; 15 + if (days < 30) return `${Math.floor(days / 7)} weeks ago`; 16 + if (days < 365) return `${Math.floor(days / 30)} months ago`; 17 + return date.toLocaleDateString(); 18 + }
+200
tests/commands/issue.test.ts
··· 246 246 }); 247 247 }); 248 248 }); 249 + 250 + describe('issue list command', () => { 251 + let mockClient: TangledApiClient; 252 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 253 + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 254 + let processExitSpy: ReturnType<typeof vi.spyOn>; 255 + 256 + beforeEach(() => { 257 + // Mock console methods 258 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 259 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 260 + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 261 + throw new Error(`process.exit(${code})`); 262 + }) as never; 263 + 264 + // Mock API client 265 + mockClient = { 266 + resumeSession: vi.fn(async () => true), 267 + } as unknown as TangledApiClient; 268 + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 269 + 270 + // Mock context 271 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 272 + owner: 'test.bsky.social', 273 + ownerType: 'handle', 274 + name: 'test-repo', 275 + remoteName: 'origin', 276 + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 277 + protocol: 'ssh', 278 + }); 279 + 280 + // Mock AT-URI builder 281 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue( 282 + 'at://did:plc:abc123/sh.tangled.repo/xyz789' 283 + ); 284 + }); 285 + 286 + afterEach(() => { 287 + vi.restoreAllMocks(); 288 + }); 289 + 290 + describe('list issues', () => { 291 + it('should list issues successfully', async () => { 292 + const mockIssues: IssueWithMetadata[] = [ 293 + { 294 + $type: 'sh.tangled.repo.issue', 295 + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 296 + title: 'First Issue', 297 + createdAt: new Date('2024-01-01').toISOString(), 298 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 299 + cid: 'bafyrei1', 300 + author: 'did:plc:abc123', 301 + }, 302 + { 303 + $type: 'sh.tangled.repo.issue', 304 + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 305 + title: 'Second Issue', 306 + createdAt: new Date('2024-01-02').toISOString(), 307 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', 308 + cid: 'bafyrei2', 309 + author: 'did:plc:abc123', 310 + }, 311 + ]; 312 + 313 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 314 + issues: mockIssues, 315 + cursor: undefined, 316 + }); 317 + 318 + const command = createIssueCommand(); 319 + await command.parseAsync(['node', 'test', 'list']); 320 + 321 + expect(issuesApi.listIssues).toHaveBeenCalledWith({ 322 + client: mockClient, 323 + repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 324 + limit: 50, 325 + }); 326 + 327 + expect(consoleLogSpy).toHaveBeenCalledWith('\nFound 2 issues:\n'); 328 + expect(consoleLogSpy).toHaveBeenCalledWith(' #issue1 First Issue'); 329 + expect(consoleLogSpy).toHaveBeenCalledWith(' #issue2 Second Issue'); 330 + }); 331 + 332 + it('should handle custom limit', async () => { 333 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 334 + issues: [], 335 + cursor: undefined, 336 + }); 337 + 338 + const command = createIssueCommand(); 339 + await command.parseAsync(['node', 'test', 'list', '--limit', '25']); 340 + 341 + expect(issuesApi.listIssues).toHaveBeenCalledWith({ 342 + client: mockClient, 343 + repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 344 + limit: 25, 345 + }); 346 + }); 347 + 348 + it('should handle empty issue list', async () => { 349 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 350 + issues: [], 351 + cursor: undefined, 352 + }); 353 + 354 + const command = createIssueCommand(); 355 + await command.parseAsync(['node', 'test', 'list']); 356 + 357 + expect(consoleLogSpy).toHaveBeenCalledWith('No issues found for this repository.'); 358 + }); 359 + }); 360 + 361 + describe('authentication required', () => { 362 + it('should fail when not authenticated', async () => { 363 + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 364 + 365 + const command = createIssueCommand(); 366 + 367 + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow( 368 + 'process.exit(1)' 369 + ); 370 + 371 + expect(consoleErrorSpy).toHaveBeenCalledWith( 372 + '✗ Not authenticated. Run "tangled auth login" first.' 373 + ); 374 + expect(processExitSpy).toHaveBeenCalledWith(1); 375 + }); 376 + }); 377 + 378 + describe('context required', () => { 379 + it('should fail when not in a Tangled repository', async () => { 380 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); 381 + 382 + const command = createIssueCommand(); 383 + 384 + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow( 385 + 'process.exit(1)' 386 + ); 387 + 388 + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); 389 + expect(processExitSpy).toHaveBeenCalledWith(1); 390 + }); 391 + }); 392 + 393 + describe('validation errors', () => { 394 + it('should fail with invalid limit (too low)', async () => { 395 + const command = createIssueCommand(); 396 + 397 + await expect(command.parseAsync(['node', 'test', 'list', '--limit', '0'])).rejects.toThrow( 398 + 'process.exit(1)' 399 + ); 400 + 401 + expect(consoleErrorSpy).toHaveBeenCalledWith( 402 + '✗ Invalid limit. Must be between 1 and 100.' 403 + ); 404 + expect(processExitSpy).toHaveBeenCalledWith(1); 405 + }); 406 + 407 + it('should fail with invalid limit (too high)', async () => { 408 + const command = createIssueCommand(); 409 + 410 + await expect( 411 + command.parseAsync(['node', 'test', 'list', '--limit', '101']) 412 + ).rejects.toThrow('process.exit(1)'); 413 + 414 + expect(consoleErrorSpy).toHaveBeenCalledWith( 415 + '✗ Invalid limit. Must be between 1 and 100.' 416 + ); 417 + expect(processExitSpy).toHaveBeenCalledWith(1); 418 + }); 419 + 420 + it('should fail with non-numeric limit', async () => { 421 + const command = createIssueCommand(); 422 + 423 + await expect( 424 + command.parseAsync(['node', 'test', 'list', '--limit', 'abc']) 425 + ).rejects.toThrow('process.exit(1)'); 426 + 427 + expect(consoleErrorSpy).toHaveBeenCalledWith( 428 + '✗ Invalid limit. Must be between 1 and 100.' 429 + ); 430 + expect(processExitSpy).toHaveBeenCalledWith(1); 431 + }); 432 + }); 433 + 434 + describe('API errors', () => { 435 + it('should handle API errors gracefully', async () => { 436 + vi.mocked(issuesApi.listIssues).mockRejectedValue(new Error('Network error')); 437 + 438 + const command = createIssueCommand(); 439 + 440 + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow( 441 + 'process.exit(1)' 442 + ); 443 + 444 + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Failed to list issues: Network error'); 445 + expect(processExitSpy).toHaveBeenCalledWith(1); 446 + }); 447 + }); 448 + });
+83
tests/utils/formatting.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import { formatDate } from '../../src/utils/formatting.js'; 3 + 4 + describe('formatDate', () => { 5 + beforeEach(() => { 6 + // Mock current date to 2024-06-15 12:00:00 UTC for consistent testing 7 + vi.useFakeTimers(); 8 + vi.setSystemTime(new Date('2024-06-15T12:00:00.000Z')); 9 + }); 10 + 11 + afterEach(() => { 12 + vi.useRealTimers(); 13 + }); 14 + 15 + it('should return "today" for current day', () => { 16 + const today = new Date('2024-06-15T10:00:00.000Z').toISOString(); 17 + expect(formatDate(today)).toBe('today'); 18 + }); 19 + 20 + it('should return "yesterday" for previous day', () => { 21 + const yesterday = new Date('2024-06-14T10:00:00.000Z').toISOString(); 22 + expect(formatDate(yesterday)).toBe('yesterday'); 23 + }); 24 + 25 + it('should return days ago for 2-6 days', () => { 26 + const twoDaysAgo = new Date('2024-06-13T10:00:00.000Z').toISOString(); 27 + expect(formatDate(twoDaysAgo)).toBe('2 days ago'); 28 + 29 + const threeDaysAgo = new Date('2024-06-12T10:00:00.000Z').toISOString(); 30 + expect(formatDate(threeDaysAgo)).toBe('3 days ago'); 31 + 32 + const sixDaysAgo = new Date('2024-06-09T10:00:00.000Z').toISOString(); 33 + expect(formatDate(sixDaysAgo)).toBe('6 days ago'); 34 + }); 35 + 36 + it('should return weeks ago for 7-29 days', () => { 37 + const oneWeekAgo = new Date('2024-06-08T10:00:00.000Z').toISOString(); 38 + expect(formatDate(oneWeekAgo)).toBe('1 weeks ago'); 39 + 40 + const twoWeeksAgo = new Date('2024-06-01T10:00:00.000Z').toISOString(); 41 + expect(formatDate(twoWeeksAgo)).toBe('2 weeks ago'); 42 + 43 + const threeWeeksAgo = new Date('2024-05-25T10:00:00.000Z').toISOString(); 44 + expect(formatDate(threeWeeksAgo)).toBe('3 weeks ago'); 45 + }); 46 + 47 + it('should return months ago for 30-364 days', () => { 48 + const oneMonthAgo = new Date('2024-05-16T10:00:00.000Z').toISOString(); 49 + expect(formatDate(oneMonthAgo)).toBe('1 months ago'); 50 + 51 + const threeMonthsAgo = new Date('2024-03-16T10:00:00.000Z').toISOString(); 52 + expect(formatDate(threeMonthsAgo)).toBe('3 months ago'); 53 + 54 + const sixMonthsAgo = new Date('2023-12-16T10:00:00.000Z').toISOString(); 55 + expect(formatDate(sixMonthsAgo)).toBe('6 months ago'); 56 + }); 57 + 58 + it('should return locale date string for 365+ days', () => { 59 + const oneYearAgo = new Date('2023-06-15T10:00:00.000Z').toISOString(); 60 + const formatted = formatDate(oneYearAgo); 61 + 62 + // The exact format depends on locale, but it should be a date string 63 + expect(formatted).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/); 64 + }); 65 + 66 + it('should handle edge case at exactly 7 days', () => { 67 + const sevenDaysAgo = new Date('2024-06-08T12:00:00.000Z').toISOString(); 68 + expect(formatDate(sevenDaysAgo)).toBe('1 weeks ago'); 69 + }); 70 + 71 + it('should handle edge case at exactly 30 days', () => { 72 + const thirtyDaysAgo = new Date('2024-05-16T12:00:00.000Z').toISOString(); 73 + expect(formatDate(thirtyDaysAgo)).toBe('1 months ago'); 74 + }); 75 + 76 + it('should handle edge case at exactly 365 days', () => { 77 + const oneYearAgo = new Date('2023-06-15T12:00:00.000Z').toISOString(); 78 + const formatted = formatDate(oneYearAgo); 79 + 80 + // Should use locale date string 81 + expect(formatted).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/); 82 + }); 83 + });