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 create' command

Add the base issue command structure with create subcommand:
- Command accepts title argument and optional body via --body or --body-file
- Validates authentication and repository context
- Builds AT-URI and creates issue record via AT Protocol
- Displays success message with issue number (rkey) and URI
- Comprehensive tests covering all input methods and error cases

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

markbennett.ca ee0201dc bc4828ac

verified
+337
+89
src/commands/issue.ts
··· 1 + import { Command } from 'commander'; 2 + import { createApiClient } from '../lib/api-client.js'; 3 + import { getCurrentRepoContext } from '../lib/context.js'; 4 + import { createIssue } from '../lib/issues-api.js'; 5 + import { buildRepoAtUri } from '../utils/at-uri.js'; 6 + import { readBodyInput } from '../utils/body-input.js'; 7 + import { validateIssueBody, validateIssueTitle } from '../utils/validation.js'; 8 + 9 + /** 10 + * Extract rkey from AT-URI 11 + */ 12 + function extractRkey(uri: string): string { 13 + const parts = uri.split('/'); 14 + return parts[parts.length - 1] || 'unknown'; 15 + } 16 + 17 + /** 18 + * Create the issue command with all subcommands 19 + */ 20 + export function createIssueCommand(): Command { 21 + const issue = new Command('issue'); 22 + issue.description('Manage issues in Tangled repositories'); 23 + 24 + issue.addCommand(createCreateCommand()); 25 + 26 + return issue; 27 + } 28 + 29 + /** 30 + * Issue create subcommand 31 + */ 32 + function createCreateCommand(): Command { 33 + return new Command('create') 34 + .description('Create a new issue') 35 + .argument('<title>', 'Issue title') 36 + .option('-b, --body <string>', 'Issue body text') 37 + .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 38 + .action(async (title: string, options: { body?: string; bodyFile?: string }) => { 39 + try { 40 + // 1. Validate auth 41 + const client = createApiClient(); 42 + if (!(await client.resumeSession())) { 43 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 44 + process.exit(1); 45 + } 46 + 47 + // 2. Get repo context 48 + const context = await getCurrentRepoContext(); 49 + if (!context) { 50 + console.error('✗ Not in a Tangled repository'); 51 + console.error('\nTo use this repository with Tangled, add a remote:'); 52 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 53 + process.exit(1); 54 + } 55 + 56 + // 3. Validate title 57 + const validTitle = validateIssueTitle(title); 58 + 59 + // 4. Handle body input 60 + const body = await readBodyInput(options.body, options.bodyFile); 61 + if (body !== undefined) { 62 + validateIssueBody(body); 63 + } 64 + 65 + // 5. Build repo AT-URI 66 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 67 + 68 + // 6. Create issue 69 + console.log('Creating issue...'); 70 + const issue = await createIssue({ 71 + client, 72 + repoAtUri, 73 + title: validTitle, 74 + body, 75 + }); 76 + 77 + // 7. Display success 78 + const rkey = extractRkey(issue.uri); 79 + console.log(`\n✓ Issue created: #${rkey}`); 80 + console.log(` Title: ${issue.title}`); 81 + console.log(` URI: ${issue.uri}`); 82 + } catch (error) { 83 + console.error( 84 + `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}` 85 + ); 86 + process.exit(1); 87 + } 88 + }); 89 + }
+248
tests/commands/issue.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import { createIssueCommand } from '../../src/commands/issue.js'; 3 + import * as apiClient from '../../src/lib/api-client.js'; 4 + import type { TangledApiClient } from '../../src/lib/api-client.js'; 5 + import * as context from '../../src/lib/context.js'; 6 + import * as issuesApi from '../../src/lib/issues-api.js'; 7 + import type { IssueWithMetadata } from '../../src/lib/issues-api.js'; 8 + import * as atUri from '../../src/utils/at-uri.js'; 9 + import * as bodyInput from '../../src/utils/body-input.js'; 10 + 11 + // Mock dependencies 12 + vi.mock('../../src/lib/api-client.js'); 13 + vi.mock('../../src/lib/issues-api.js'); 14 + vi.mock('../../src/lib/context.js'); 15 + vi.mock('../../src/utils/at-uri.js'); 16 + vi.mock('../../src/utils/body-input.js'); 17 + 18 + describe('issue create command', () => { 19 + let mockClient: TangledApiClient; 20 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 21 + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 22 + let processExitSpy: ReturnType<typeof vi.spyOn>; 23 + 24 + beforeEach(() => { 25 + // Mock console methods 26 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 27 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 28 + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 29 + throw new Error(`process.exit(${code})`); 30 + }) as never; 31 + 32 + // Mock API client 33 + mockClient = { 34 + resumeSession: vi.fn(async () => true), 35 + } as unknown as TangledApiClient; 36 + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 37 + 38 + // Mock context 39 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 40 + owner: 'test.bsky.social', 41 + ownerType: 'handle', 42 + name: 'test-repo', 43 + remoteName: 'origin', 44 + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 45 + protocol: 'ssh', 46 + }); 47 + 48 + // Mock AT-URI builder 49 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue( 50 + 'at://did:plc:abc123/sh.tangled.repo/test-repo' 51 + ); 52 + 53 + // Mock body input 54 + vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 55 + }); 56 + 57 + afterEach(() => { 58 + vi.restoreAllMocks(); 59 + }); 60 + 61 + describe('create with --body flag', () => { 62 + it('should create issue with body text', async () => { 63 + const mockIssue: IssueWithMetadata = { 64 + $type: 'sh.tangled.repo.issue', 65 + repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 66 + title: 'Test Issue', 67 + body: 'Test body', 68 + createdAt: new Date().toISOString(), 69 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 70 + cid: 'bafyreiabc123', 71 + author: 'did:plc:abc123', 72 + }; 73 + 74 + vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Test body'); 75 + vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 76 + 77 + const command = createIssueCommand(); 78 + await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', 'Test body']); 79 + 80 + expect(issuesApi.createIssue).toHaveBeenCalledWith({ 81 + client: mockClient, 82 + repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 83 + title: 'Test Issue', 84 + body: 'Test body', 85 + }); 86 + 87 + expect(consoleLogSpy).toHaveBeenCalledWith('Creating issue...'); 88 + expect(consoleLogSpy).toHaveBeenCalledWith('\n✓ Issue created: #abc123'); 89 + }); 90 + }); 91 + 92 + describe('create with --body-file flag', () => { 93 + it('should create issue with body from file', async () => { 94 + const mockIssue: IssueWithMetadata = { 95 + $type: 'sh.tangled.repo.issue', 96 + repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 97 + title: 'Test Issue', 98 + body: 'Body from file', 99 + createdAt: new Date().toISOString(), 100 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789', 101 + cid: 'bafyreixyz789', 102 + author: 'did:plc:abc123', 103 + }; 104 + 105 + vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Body from file'); 106 + vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 107 + 108 + const command = createIssueCommand(); 109 + await command.parseAsync([ 110 + 'node', 111 + 'test', 112 + 'create', 113 + 'Test Issue', 114 + '--body-file', 115 + '/tmp/body.txt', 116 + ]); 117 + 118 + expect(bodyInput.readBodyInput).toHaveBeenCalledWith(undefined, '/tmp/body.txt'); 119 + expect(issuesApi.createIssue).toHaveBeenCalledWith({ 120 + client: mockClient, 121 + repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 122 + title: 'Test Issue', 123 + body: 'Body from file', 124 + }); 125 + }); 126 + }); 127 + 128 + describe('create without body', () => { 129 + it('should create issue without body', async () => { 130 + const mockIssue: IssueWithMetadata = { 131 + $type: 'sh.tangled.repo.issue', 132 + repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 133 + title: 'Test Issue', 134 + createdAt: new Date().toISOString(), 135 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/test123', 136 + cid: 'bafyreitest123', 137 + author: 'did:plc:abc123', 138 + }; 139 + 140 + vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 141 + vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 142 + 143 + const command = createIssueCommand(); 144 + await command.parseAsync(['node', 'test', 'create', 'Test Issue']); 145 + 146 + expect(issuesApi.createIssue).toHaveBeenCalledWith({ 147 + client: mockClient, 148 + repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 149 + title: 'Test Issue', 150 + body: undefined, 151 + }); 152 + }); 153 + }); 154 + 155 + describe('authentication required', () => { 156 + it('should fail when not authenticated', async () => { 157 + vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 158 + 159 + const command = createIssueCommand(); 160 + 161 + await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 162 + 'process.exit(1)' 163 + ); 164 + 165 + expect(consoleErrorSpy).toHaveBeenCalledWith( 166 + '✗ Not authenticated. Run "tangled auth login" first.' 167 + ); 168 + expect(processExitSpy).toHaveBeenCalledWith(1); 169 + }); 170 + }); 171 + 172 + describe('context required', () => { 173 + it('should fail when not in a Tangled repository', async () => { 174 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); 175 + 176 + const command = createIssueCommand(); 177 + 178 + await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 179 + 'process.exit(1)' 180 + ); 181 + 182 + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); 183 + expect(processExitSpy).toHaveBeenCalledWith(1); 184 + }); 185 + }); 186 + 187 + describe('validation errors', () => { 188 + it('should fail with empty title', async () => { 189 + const command = createIssueCommand(); 190 + 191 + await expect(command.parseAsync(['node', 'test', 'create', ''])).rejects.toThrow( 192 + 'process.exit(1)' 193 + ); 194 + 195 + expect(consoleErrorSpy).toHaveBeenCalledWith( 196 + expect.stringContaining('Issue title cannot be empty') 197 + ); 198 + expect(processExitSpy).toHaveBeenCalledWith(1); 199 + }); 200 + 201 + it('should fail with title over 256 characters', async () => { 202 + const longTitle = 'A'.repeat(257); 203 + 204 + const command = createIssueCommand(); 205 + 206 + await expect(command.parseAsync(['node', 'test', 'create', longTitle])).rejects.toThrow( 207 + 'process.exit(1)' 208 + ); 209 + 210 + expect(consoleErrorSpy).toHaveBeenCalledWith( 211 + expect.stringContaining('Issue title must be 256 characters or less') 212 + ); 213 + expect(processExitSpy).toHaveBeenCalledWith(1); 214 + }); 215 + 216 + it('should fail with body over 50,000 characters', async () => { 217 + const longBody = 'A'.repeat(50001); 218 + 219 + vi.mocked(bodyInput.readBodyInput).mockResolvedValue(longBody); 220 + 221 + const command = createIssueCommand(); 222 + 223 + await expect( 224 + command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', longBody]) 225 + ).rejects.toThrow('process.exit(1)'); 226 + 227 + expect(consoleErrorSpy).toHaveBeenCalledWith( 228 + expect.stringContaining('Issue body must be 50,000 characters or less') 229 + ); 230 + expect(processExitSpy).toHaveBeenCalledWith(1); 231 + }); 232 + }); 233 + 234 + describe('API errors', () => { 235 + it('should handle API errors gracefully', async () => { 236 + vi.mocked(issuesApi.createIssue).mockRejectedValue(new Error('Network error')); 237 + 238 + const command = createIssueCommand(); 239 + 240 + await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 241 + 'process.exit(1)' 242 + ); 243 + 244 + expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Failed to create issue: Network error'); 245 + expect(processExitSpy).toHaveBeenCalledWith(1); 246 + }); 247 + }); 248 + });