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

Apply code formatting to source files

Auto-format source files with Biome formatter (import organization,
spacing, etc.). Generated lexicon files are now excluded from
formatting.

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

markbennett.ca e54e8305 f4056f79

verified
+1209 -1256
+1 -7
package.json
··· 28 28 "type": "git", 29 29 "url": "git@tangled.org:markbennett.ca/tangled-cli" 30 30 }, 31 - "keywords": [ 32 - "git", 33 - "tangled", 34 - "pds", 35 - "atproto", 36 - "cli" 37 - ], 31 + "keywords": ["git", "tangled", "pds", "atproto", "cli"], 38 32 "author": "Mark Bennett", 39 33 "license": "MIT", 40 34 "dependencies": {
+225 -233
src/lib/issues-api.ts
··· 1 - import type { TangledApiClient } from './api-client.js'; 1 + import type { Record as IssueRecord } from '../lexicon/types/sh/tangled/repo/issue.js'; 2 2 import { parseAtUri } from '../utils/at-uri.js'; 3 3 import { requireAuth } from '../utils/auth-helpers.js'; 4 - import type { Record as IssueRecord } from '../lexicon/types/sh/tangled/repo/issue.js'; 4 + import type { TangledApiClient } from './api-client.js'; 5 5 6 6 // Re-export the generated issue record type for convenience 7 7 export type { IssueRecord }; ··· 10 10 * Issue record with metadata 11 11 */ 12 12 export interface IssueWithMetadata extends IssueRecord { 13 - uri: string; // AT-URI of the issue 14 - cid: string; // Content ID 15 - author: string; // Creator's DID 13 + uri: string; // AT-URI of the issue 14 + cid: string; // Content ID 15 + author: string; // Creator's DID 16 16 } 17 17 18 18 /** 19 19 * Parameters for creating an issue 20 20 */ 21 21 export interface CreateIssueParams { 22 - client: TangledApiClient; 23 - repoAtUri: string; 24 - title: string; 25 - body?: string; 22 + client: TangledApiClient; 23 + repoAtUri: string; 24 + title: string; 25 + body?: string; 26 26 } 27 27 28 28 /** 29 29 * Parameters for listing issues 30 30 */ 31 31 export interface ListIssuesParams { 32 - client: TangledApiClient; 33 - repoAtUri: string; 34 - limit?: number; 35 - cursor?: string; 32 + client: TangledApiClient; 33 + repoAtUri: string; 34 + limit?: number; 35 + cursor?: string; 36 36 } 37 37 38 38 /** 39 39 * Parameters for getting a specific issue 40 40 */ 41 41 export interface GetIssueParams { 42 - client: TangledApiClient; 43 - issueUri: string; 42 + client: TangledApiClient; 43 + issueUri: string; 44 44 } 45 45 46 46 /** 47 47 * Parameters for updating an issue 48 48 */ 49 49 export interface UpdateIssueParams { 50 - client: TangledApiClient; 51 - issueUri: string; 52 - title?: string; 53 - body?: string; 50 + client: TangledApiClient; 51 + issueUri: string; 52 + title?: string; 53 + body?: string; 54 54 } 55 55 56 56 /** 57 57 * Parameters for closing an issue 58 58 */ 59 59 export interface CloseIssueParams { 60 - client: TangledApiClient; 61 - issueUri: string; 60 + client: TangledApiClient; 61 + issueUri: string; 62 62 } 63 63 64 64 /** 65 65 * Parameters for deleting an issue 66 66 */ 67 67 export interface DeleteIssueParams { 68 - client: TangledApiClient; 69 - issueUri: string; 68 + client: TangledApiClient; 69 + issueUri: string; 70 70 } 71 71 72 72 /** ··· 75 75 * @returns Parsed URI components 76 76 */ 77 77 function parseIssueUri(issueUri: string): { 78 - did: string; 79 - collection: string; 80 - rkey: string; 78 + did: string; 79 + collection: string; 80 + rkey: string; 81 81 } { 82 - const parsed = parseAtUri(issueUri); 83 - if (!parsed || !parsed.rkey) { 84 - throw new Error(`Invalid issue AT-URI: ${issueUri}`); 85 - } 82 + const parsed = parseAtUri(issueUri); 83 + if (!parsed || !parsed.rkey) { 84 + throw new Error(`Invalid issue AT-URI: ${issueUri}`); 85 + } 86 86 87 - return { 88 - did: parsed.did, 89 - collection: parsed.collection, 90 - rkey: parsed.rkey, 91 - }; 87 + return { 88 + did: parsed.did, 89 + collection: parsed.collection, 90 + rkey: parsed.rkey, 91 + }; 92 92 } 93 93 94 94 /** 95 95 * Create a new issue 96 96 */ 97 - export async function createIssue( 98 - params: CreateIssueParams, 99 - ): Promise<IssueWithMetadata> { 100 - const { client, repoAtUri, title, body } = params; 97 + export async function createIssue(params: CreateIssueParams): Promise<IssueWithMetadata> { 98 + const { client, repoAtUri, title, body } = params; 101 99 102 - // Validate authentication 103 - const session = await requireAuth(client); 100 + // Validate authentication 101 + const session = await requireAuth(client); 104 102 105 - // Build issue record 106 - const record: IssueRecord = { 107 - $type: 'sh.tangled.repo.issue', 108 - repo: repoAtUri, 109 - title, 110 - body, 111 - createdAt: new Date().toISOString(), 112 - }; 103 + // Build issue record 104 + const record: IssueRecord = { 105 + $type: 'sh.tangled.repo.issue', 106 + repo: repoAtUri, 107 + title, 108 + body, 109 + createdAt: new Date().toISOString(), 110 + }; 113 111 114 - try { 115 - // Create record via AT Protocol 116 - const response = await client.getAgent().com.atproto.repo.createRecord({ 117 - repo: session.did, 118 - collection: 'sh.tangled.repo.issue', 119 - record, 120 - }); 112 + try { 113 + // Create record via AT Protocol 114 + const response = await client.getAgent().com.atproto.repo.createRecord({ 115 + repo: session.did, 116 + collection: 'sh.tangled.repo.issue', 117 + record, 118 + }); 121 119 122 - return { 123 - ...record, 124 - uri: response.data.uri, 125 - cid: response.data.cid, 126 - author: session.did, 127 - }; 128 - } catch (error) { 129 - if (error instanceof Error) { 130 - throw new Error(`Failed to create issue: ${error.message}`); 131 - } 132 - throw new Error('Failed to create issue: Unknown error'); 133 - } 120 + return { 121 + ...record, 122 + uri: response.data.uri, 123 + cid: response.data.cid, 124 + author: session.did, 125 + }; 126 + } catch (error) { 127 + if (error instanceof Error) { 128 + throw new Error(`Failed to create issue: ${error.message}`); 129 + } 130 + throw new Error('Failed to create issue: Unknown error'); 131 + } 134 132 } 135 133 136 134 /** 137 135 * List issues for a repository 138 136 */ 139 - export async function listIssues( 140 - params: ListIssuesParams, 141 - ): Promise<{ 142 - issues: IssueWithMetadata[]; 143 - cursor?: string; 137 + export async function listIssues(params: ListIssuesParams): Promise<{ 138 + issues: IssueWithMetadata[]; 139 + cursor?: string; 144 140 }> { 145 - const { client, repoAtUri, limit = 50, cursor } = params; 141 + const { client, repoAtUri, limit = 50, cursor } = params; 146 142 147 - // Validate authentication 148 - await requireAuth(client); 143 + // Validate authentication 144 + await requireAuth(client); 149 145 150 - // Extract owner DID from repo AT-URI 151 - const parsed = parseAtUri(repoAtUri); 152 - if (!parsed) { 153 - throw new Error(`Invalid repository AT-URI: ${repoAtUri}`); 154 - } 146 + // Extract owner DID from repo AT-URI 147 + const parsed = parseAtUri(repoAtUri); 148 + if (!parsed) { 149 + throw new Error(`Invalid repository AT-URI: ${repoAtUri}`); 150 + } 155 151 156 - const ownerDid = parsed.did; 152 + const ownerDid = parsed.did; 157 153 158 - try { 159 - // List all issue records for the owner 160 - const response = await client.getAgent().com.atproto.repo.listRecords({ 161 - repo: ownerDid, 162 - collection: 'sh.tangled.repo.issue', 163 - limit, 164 - cursor, 165 - }); 154 + try { 155 + // List all issue records for the owner 156 + const response = await client.getAgent().com.atproto.repo.listRecords({ 157 + repo: ownerDid, 158 + collection: 'sh.tangled.repo.issue', 159 + limit, 160 + cursor, 161 + }); 166 162 167 - // Filter to only issues for this specific repository 168 - const issues: IssueWithMetadata[] = response.data.records 169 - .filter((record) => { 170 - const issueRecord = record.value as IssueRecord; 171 - return issueRecord.repo === repoAtUri; 172 - }) 173 - .map((record) => ({ 174 - ...(record.value as IssueRecord), 175 - uri: record.uri, 176 - cid: record.cid, 177 - author: ownerDid, 178 - })); 163 + // Filter to only issues for this specific repository 164 + const issues: IssueWithMetadata[] = response.data.records 165 + .filter((record) => { 166 + const issueRecord = record.value as IssueRecord; 167 + return issueRecord.repo === repoAtUri; 168 + }) 169 + .map((record) => ({ 170 + ...(record.value as IssueRecord), 171 + uri: record.uri, 172 + cid: record.cid, 173 + author: ownerDid, 174 + })); 179 175 180 - return { 181 - issues, 182 - cursor: response.data.cursor, 183 - }; 184 - } catch (error) { 185 - if (error instanceof Error) { 186 - throw new Error(`Failed to list issues: ${error.message}`); 187 - } 188 - throw new Error('Failed to list issues: Unknown error'); 189 - } 176 + return { 177 + issues, 178 + cursor: response.data.cursor, 179 + }; 180 + } catch (error) { 181 + if (error instanceof Error) { 182 + throw new Error(`Failed to list issues: ${error.message}`); 183 + } 184 + throw new Error('Failed to list issues: Unknown error'); 185 + } 190 186 } 191 187 192 188 /** 193 189 * Get a specific issue 194 190 */ 195 - export async function getIssue( 196 - params: GetIssueParams, 197 - ): Promise<IssueWithMetadata> { 198 - const { client, issueUri } = params; 191 + export async function getIssue(params: GetIssueParams): Promise<IssueWithMetadata> { 192 + const { client, issueUri } = params; 199 193 200 - // Validate authentication 201 - await requireAuth(client); 194 + // Validate authentication 195 + await requireAuth(client); 202 196 203 - // Parse issue URI 204 - const { did, collection, rkey } = parseIssueUri(issueUri); 197 + // Parse issue URI 198 + const { did, collection, rkey } = parseIssueUri(issueUri); 205 199 206 - try { 207 - // Get record via AT Protocol 208 - const response = await client.getAgent().com.atproto.repo.getRecord({ 209 - repo: did, 210 - collection, 211 - rkey, 212 - }); 200 + try { 201 + // Get record via AT Protocol 202 + const response = await client.getAgent().com.atproto.repo.getRecord({ 203 + repo: did, 204 + collection, 205 + rkey, 206 + }); 213 207 214 - const record = response.data.value as IssueRecord; 208 + const record = response.data.value as IssueRecord; 215 209 216 - return { 217 - ...record, 218 - uri: response.data.uri, 219 - cid: response.data.cid as string, // CID is always present in AT Protocol responses 220 - author: did, 221 - }; 222 - } catch (error) { 223 - if (error instanceof Error) { 224 - if (error.message.includes('not found')) { 225 - throw new Error(`Issue not found: ${issueUri}`); 226 - } 227 - throw new Error(`Failed to get issue: ${error.message}`); 228 - } 229 - throw new Error('Failed to get issue: Unknown error'); 230 - } 210 + return { 211 + ...record, 212 + uri: response.data.uri, 213 + cid: response.data.cid as string, // CID is always present in AT Protocol responses 214 + author: did, 215 + }; 216 + } catch (error) { 217 + if (error instanceof Error) { 218 + if (error.message.includes('not found')) { 219 + throw new Error(`Issue not found: ${issueUri}`); 220 + } 221 + throw new Error(`Failed to get issue: ${error.message}`); 222 + } 223 + throw new Error('Failed to get issue: Unknown error'); 224 + } 231 225 } 232 226 233 227 /** 234 228 * Update an issue (title and/or body) 235 229 */ 236 - export async function updateIssue( 237 - params: UpdateIssueParams, 238 - ): Promise<IssueWithMetadata> { 239 - const { client, issueUri, title, body } = params; 230 + export async function updateIssue(params: UpdateIssueParams): Promise<IssueWithMetadata> { 231 + const { client, issueUri, title, body } = params; 240 232 241 - // Validate authentication 242 - const session = await requireAuth(client); 233 + // Validate authentication 234 + const session = await requireAuth(client); 243 235 244 - // Parse issue URI 245 - const { did, collection, rkey } = parseIssueUri(issueUri); 236 + // Parse issue URI 237 + const { did, collection, rkey } = parseIssueUri(issueUri); 246 238 247 - // Verify user owns the issue 248 - if (did !== session.did) { 249 - throw new Error('Cannot update issue: you are not the author'); 250 - } 239 + // Verify user owns the issue 240 + if (did !== session.did) { 241 + throw new Error('Cannot update issue: you are not the author'); 242 + } 251 243 252 - try { 253 - // Get current issue to merge with updates 254 - const currentIssue = await getIssue({ client, issueUri }); 244 + try { 245 + // Get current issue to merge with updates 246 + const currentIssue = await getIssue({ client, issueUri }); 255 247 256 - // Build updated record (merge existing with new values) 257 - const updatedRecord: IssueRecord = { 258 - ...currentIssue, 259 - ...(title !== undefined && { title }), 260 - ...(body !== undefined && { body }), 261 - }; 248 + // Build updated record (merge existing with new values) 249 + const updatedRecord: IssueRecord = { 250 + ...currentIssue, 251 + ...(title !== undefined && { title }), 252 + ...(body !== undefined && { body }), 253 + }; 262 254 263 - // Update record with CID swap for atomic update 264 - const response = await client.getAgent().com.atproto.repo.putRecord({ 265 - repo: did, 266 - collection, 267 - rkey, 268 - record: updatedRecord, 269 - swapRecord: currentIssue.cid, 270 - }); 255 + // Update record with CID swap for atomic update 256 + const response = await client.getAgent().com.atproto.repo.putRecord({ 257 + repo: did, 258 + collection, 259 + rkey, 260 + record: updatedRecord, 261 + swapRecord: currentIssue.cid, 262 + }); 271 263 272 - return { 273 - ...updatedRecord, 274 - uri: issueUri, 275 - cid: response.data.cid, 276 - author: did, 277 - }; 278 - } catch (error) { 279 - if (error instanceof Error) { 280 - throw new Error(`Failed to update issue: ${error.message}`); 281 - } 282 - throw new Error('Failed to update issue: Unknown error'); 283 - } 264 + return { 265 + ...updatedRecord, 266 + uri: issueUri, 267 + cid: response.data.cid, 268 + author: did, 269 + }; 270 + } catch (error) { 271 + if (error instanceof Error) { 272 + throw new Error(`Failed to update issue: ${error.message}`); 273 + } 274 + throw new Error('Failed to update issue: Unknown error'); 275 + } 284 276 } 285 277 286 278 /** 287 279 * Close an issue by creating/updating a state record 288 280 */ 289 281 export async function closeIssue(params: CloseIssueParams): Promise<void> { 290 - const { client, issueUri } = params; 282 + const { client, issueUri } = params; 291 283 292 - // Validate authentication 293 - const session = await requireAuth(client); 284 + // Validate authentication 285 + const session = await requireAuth(client); 294 286 295 - try { 296 - // Verify issue exists 297 - await getIssue({ client, issueUri }); 287 + try { 288 + // Verify issue exists 289 + await getIssue({ client, issueUri }); 298 290 299 - // Create state record 300 - const stateRecord = { 301 - $type: 'sh.tangled.repo.issue.state', 302 - issue: issueUri, 303 - state: 'sh.tangled.repo.issue.state.closed', 304 - }; 291 + // Create state record 292 + const stateRecord = { 293 + $type: 'sh.tangled.repo.issue.state', 294 + issue: issueUri, 295 + state: 'sh.tangled.repo.issue.state.closed', 296 + }; 305 297 306 - // Create state record 307 - await client.getAgent().com.atproto.repo.createRecord({ 308 - repo: session.did, 309 - collection: 'sh.tangled.repo.issue.state', 310 - record: stateRecord, 311 - }); 312 - } catch (error) { 313 - if (error instanceof Error) { 314 - throw new Error(`Failed to close issue: ${error.message}`); 315 - } 316 - throw new Error('Failed to close issue: Unknown error'); 317 - } 298 + // Create state record 299 + await client.getAgent().com.atproto.repo.createRecord({ 300 + repo: session.did, 301 + collection: 'sh.tangled.repo.issue.state', 302 + record: stateRecord, 303 + }); 304 + } catch (error) { 305 + if (error instanceof Error) { 306 + throw new Error(`Failed to close issue: ${error.message}`); 307 + } 308 + throw new Error('Failed to close issue: Unknown error'); 309 + } 318 310 } 319 311 320 312 /** 321 313 * Delete an issue 322 314 */ 323 315 export async function deleteIssue(params: DeleteIssueParams): Promise<void> { 324 - const { client, issueUri } = params; 316 + const { client, issueUri } = params; 325 317 326 - // Validate authentication 327 - const session = await requireAuth(client); 318 + // Validate authentication 319 + const session = await requireAuth(client); 328 320 329 - // Parse issue URI 330 - const { did, collection, rkey } = parseIssueUri(issueUri); 321 + // Parse issue URI 322 + const { did, collection, rkey } = parseIssueUri(issueUri); 331 323 332 - // Verify user owns the issue 333 - if (did !== session.did) { 334 - throw new Error('Cannot delete issue: you are not the author'); 335 - } 324 + // Verify user owns the issue 325 + if (did !== session.did) { 326 + throw new Error('Cannot delete issue: you are not the author'); 327 + } 336 328 337 - try { 338 - // Delete record via AT Protocol 339 - await client.getAgent().com.atproto.repo.deleteRecord({ 340 - repo: did, 341 - collection, 342 - rkey, 343 - }); 344 - } catch (error) { 345 - if (error instanceof Error) { 346 - if (error.message.includes('not found')) { 347 - throw new Error(`Issue not found: ${issueUri}`); 348 - } 349 - throw new Error(`Failed to delete issue: ${error.message}`); 350 - } 351 - throw new Error('Failed to delete issue: Unknown error'); 352 - } 329 + try { 330 + // Delete record via AT Protocol 331 + await client.getAgent().com.atproto.repo.deleteRecord({ 332 + repo: did, 333 + collection, 334 + rkey, 335 + }); 336 + } catch (error) { 337 + if (error instanceof Error) { 338 + if (error.message.includes('not found')) { 339 + throw new Error(`Issue not found: ${issueUri}`); 340 + } 341 + throw new Error(`Failed to delete issue: ${error.message}`); 342 + } 343 + throw new Error('Failed to delete issue: Unknown error'); 344 + } 353 345 }
+49 -49
src/utils/at-uri.ts
··· 6 6 * @returns Parsed components or null if invalid 7 7 */ 8 8 export function parseAtUri(uri: string): { 9 - did: string; 10 - collection: string; 11 - rkey?: string; 9 + did: string; 10 + collection: string; 11 + rkey?: string; 12 12 } | null { 13 - // AT-URI format: at://did:method:identifier/collection[/rkey] 14 - const match = uri.match(/^at:\/\/(did:[a-z]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*)(?:\/([a-zA-Z0-9._-]+))?$/); 13 + // AT-URI format: at://did:method:identifier/collection[/rkey] 14 + const match = uri.match( 15 + /^at:\/\/(did:[a-z]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*)(?:\/([a-zA-Z0-9._-]+))?$/ 16 + ); 15 17 16 - if (!match) { 17 - return null; 18 - } 18 + if (!match) { 19 + return null; 20 + } 19 21 20 - const [, did, collection, rkey] = match; 21 - return { 22 - did, 23 - collection, 24 - ...(rkey && { rkey }), 25 - }; 22 + const [, did, collection, rkey] = match; 23 + return { 24 + did, 25 + collection, 26 + ...(rkey && { rkey }), 27 + }; 26 28 } 27 29 28 30 /** ··· 33 35 * @throws Error if handle cannot be resolved 34 36 */ 35 37 export async function resolveHandleToDid( 36 - handle: string, 37 - client: TangledApiClient, 38 + handle: string, 39 + client: TangledApiClient 38 40 ): Promise<string> { 39 - // Strip leading @ if present 40 - const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; 41 + // Strip leading @ if present 42 + const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; 41 43 42 - try { 43 - const response = await client.getAgent().com.atproto.identity.resolveHandle({ 44 - handle: cleanHandle, 45 - }); 44 + try { 45 + const response = await client.getAgent().com.atproto.identity.resolveHandle({ 46 + handle: cleanHandle, 47 + }); 46 48 47 - if (!response.data.did) { 48 - throw new Error(`No DID found for handle: ${cleanHandle}`); 49 - } 49 + if (!response.data.did) { 50 + throw new Error(`No DID found for handle: ${cleanHandle}`); 51 + } 50 52 51 - return response.data.did; 52 - } catch (error) { 53 - if (error instanceof Error) { 54 - throw new Error( 55 - `Failed to resolve handle '${cleanHandle}': ${error.message}`, 56 - ); 57 - } 58 - throw new Error(`Failed to resolve handle '${cleanHandle}': Unknown error`); 59 - } 53 + return response.data.did; 54 + } catch (error) { 55 + if (error instanceof Error) { 56 + throw new Error(`Failed to resolve handle '${cleanHandle}': ${error.message}`); 57 + } 58 + throw new Error(`Failed to resolve handle '${cleanHandle}': Unknown error`); 59 + } 60 60 } 61 61 62 62 /** ··· 67 67 * @returns AT-URI string (e.g., "at://did:plc:abc/sh.tangled.repo/repoName") 68 68 */ 69 69 export async function buildRepoAtUri( 70 - ownerDidOrHandle: string, 71 - repoName: string, 72 - client: TangledApiClient, 70 + ownerDidOrHandle: string, 71 + repoName: string, 72 + client: TangledApiClient 73 73 ): Promise<string> { 74 - // Check if owner is already a DID 75 - const isDid = ownerDidOrHandle.startsWith('did:'); 74 + // Check if owner is already a DID 75 + const isDid = ownerDidOrHandle.startsWith('did:'); 76 76 77 - let did: string; 78 - if (isDid) { 79 - did = ownerDidOrHandle; 80 - } else { 81 - // Resolve handle to DID 82 - did = await resolveHandleToDid(ownerDidOrHandle, client); 83 - } 77 + let did: string; 78 + if (isDid) { 79 + did = ownerDidOrHandle; 80 + } else { 81 + // Resolve handle to DID 82 + did = await resolveHandleToDid(ownerDidOrHandle, client); 83 + } 84 84 85 - // Construct AT-URI for repository 86 - // Format: at://{did}/sh.tangled.repo/{repoName} 87 - return `at://${did}/sh.tangled.repo/${repoName}`; 85 + // Construct AT-URI for repository 86 + // Format: at://{did}/sh.tangled.repo/{repoName} 87 + return `at://${did}/sh.tangled.repo/${repoName}`; 88 88 }
+10 -10
src/utils/auth-helpers.ts
··· 6 6 * @returns The current session with did and handle 7 7 */ 8 8 export async function requireAuth(client: TangledApiClient): Promise<{ 9 - did: string; 10 - handle: string; 9 + did: string; 10 + handle: string; 11 11 }> { 12 - if (!(await client.isAuthenticated())) { 13 - throw new Error('Must be authenticated. Run "tangled auth login" first.'); 14 - } 12 + if (!(await client.isAuthenticated())) { 13 + throw new Error('Must be authenticated. Run "tangled auth login" first.'); 14 + } 15 15 16 - const session = client.getSession(); 17 - if (!session) { 18 - throw new Error('No active session found'); 19 - } 16 + const session = client.getSession(); 17 + if (!session) { 18 + throw new Error('No active session found'); 19 + } 20 20 21 - return session; 21 + return session; 22 22 }
+60 -64
src/utils/body-input.ts
··· 11 11 * @throws Error if file doesn't exist or cannot be read 12 12 */ 13 13 export async function readBodyInput( 14 - bodyString?: string, 15 - bodyFilePath?: string, 14 + bodyString?: string, 15 + bodyFilePath?: string 16 16 ): Promise<string | undefined> { 17 - // Error if both are provided 18 - if (bodyString !== undefined && bodyFilePath !== undefined) { 19 - throw new Error( 20 - 'Cannot specify both --body and --body-file. Choose one input method.', 21 - ); 22 - } 17 + // Error if both are provided 18 + if (bodyString !== undefined && bodyFilePath !== undefined) { 19 + throw new Error('Cannot specify both --body and --body-file. Choose one input method.'); 20 + } 23 21 24 - // Direct string input (including empty string) 25 - if (bodyString !== undefined) { 26 - return bodyString; 27 - } 22 + // Direct string input (including empty string) 23 + if (bodyString !== undefined) { 24 + return bodyString; 25 + } 28 26 29 - // File or stdin input 30 - if (bodyFilePath) { 31 - // Read from stdin 32 - if (bodyFilePath === '-') { 33 - return await readFromStdin(); 34 - } 27 + // File or stdin input 28 + if (bodyFilePath) { 29 + // Read from stdin 30 + if (bodyFilePath === '-') { 31 + return await readFromStdin(); 32 + } 35 33 36 - // Read from file 37 - try { 38 - const stats = await fs.stat(bodyFilePath); 34 + // Read from file 35 + try { 36 + const stats = await fs.stat(bodyFilePath); 39 37 40 - if (stats.isDirectory()) { 41 - throw new Error(`'${bodyFilePath}' is a directory, not a file`); 42 - } 38 + if (stats.isDirectory()) { 39 + throw new Error(`'${bodyFilePath}' is a directory, not a file`); 40 + } 43 41 44 - const content = await fs.readFile(bodyFilePath, 'utf-8'); 45 - return content; 46 - } catch (error) { 47 - if (error instanceof Error) { 48 - // Re-throw our custom directory error 49 - if (error.message.includes('is a directory')) { 50 - throw error; 51 - } 42 + const content = await fs.readFile(bodyFilePath, 'utf-8'); 43 + return content; 44 + } catch (error) { 45 + if (error instanceof Error) { 46 + // Re-throw our custom directory error 47 + if (error.message.includes('is a directory')) { 48 + throw error; 49 + } 52 50 53 - // Handle ENOENT (file not found) 54 - if ('code' in error && error.code === 'ENOENT') { 55 - throw new Error(`File not found: ${bodyFilePath}`); 56 - } 51 + // Handle ENOENT (file not found) 52 + if ('code' in error && error.code === 'ENOENT') { 53 + throw new Error(`File not found: ${bodyFilePath}`); 54 + } 57 55 58 - // Handle EACCES (permission denied) 59 - if ('code' in error && error.code === 'EACCES') { 60 - throw new Error(`Permission denied: ${bodyFilePath}`); 61 - } 56 + // Handle EACCES (permission denied) 57 + if ('code' in error && error.code === 'EACCES') { 58 + throw new Error(`Permission denied: ${bodyFilePath}`); 59 + } 62 60 63 - throw new Error( 64 - `Failed to read file '${bodyFilePath}': ${error.message}`, 65 - ); 66 - } 61 + throw new Error(`Failed to read file '${bodyFilePath}': ${error.message}`); 62 + } 67 63 68 - throw new Error(`Failed to read file '${bodyFilePath}': Unknown error`); 69 - } 70 - } 64 + throw new Error(`Failed to read file '${bodyFilePath}': Unknown error`); 65 + } 66 + } 71 67 72 - // No input provided 73 - return undefined; 68 + // No input provided 69 + return undefined; 74 70 } 75 71 76 72 /** ··· 78 74 * @returns Content from stdin as string 79 75 */ 80 76 async function readFromStdin(): Promise<string> { 81 - return new Promise((resolve, reject) => { 82 - const chunks: Buffer[] = []; 77 + return new Promise((resolve, reject) => { 78 + const chunks: Buffer[] = []; 83 79 84 - process.stdin.on('data', (chunk: Buffer) => { 85 - chunks.push(chunk); 86 - }); 80 + process.stdin.on('data', (chunk: Buffer) => { 81 + chunks.push(chunk); 82 + }); 87 83 88 - process.stdin.on('end', () => { 89 - const content = Buffer.concat(chunks).toString('utf-8'); 90 - resolve(content); 91 - }); 84 + process.stdin.on('end', () => { 85 + const content = Buffer.concat(chunks).toString('utf-8'); 86 + resolve(content); 87 + }); 92 88 93 - process.stdin.on('error', (error: Error) => { 94 - reject(new Error(`Failed to read from stdin: ${error.message}`)); 95 - }); 89 + process.stdin.on('error', (error: Error) => { 90 + reject(new Error(`Failed to read from stdin: ${error.message}`)); 91 + }); 96 92 97 - // Resume stdin in case it's paused 98 - process.stdin.resume(); 99 - }); 93 + // Resume stdin in case it's paused 94 + process.stdin.resume(); 95 + }); 100 96 }
+14 -14
src/utils/validation.ts
··· 136 136 * Titles must be 1-256 characters 137 137 */ 138 138 export const issueTitleSchema = z 139 - .string() 140 - .min(1, 'Issue title cannot be empty') 141 - .max(256, 'Issue title must be 256 characters or less'); 139 + .string() 140 + .min(1, 'Issue title cannot be empty') 141 + .max(256, 'Issue title must be 256 characters or less'); 142 142 143 143 /** 144 144 * Validation schema for issue body 145 145 * Body is optional but limited to 50,000 characters 146 146 */ 147 147 export const issueBodySchema = z 148 - .string() 149 - .max(50000, 'Issue body must be 50,000 characters or less') 150 - .optional(); 148 + .string() 149 + .max(50000, 'Issue body must be 50,000 characters or less') 150 + .optional(); 151 151 152 152 /** 153 153 * Validation schema for AT-URI 154 154 * Format: at://did:method:identifier/collection[/rkey] 155 155 */ 156 156 export const atUriSchema = z 157 - .string() 158 - .regex( 159 - /^at:\/\/did:[a-z]+:[a-zA-Z0-9._:%-]+\/[a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*(?:\/[a-zA-Z0-9._-]+)?$/, 160 - 'Invalid AT-URI format. Expected: at://did:method:id/collection[/rkey]', 161 - ); 157 + .string() 158 + .regex( 159 + /^at:\/\/did:[a-z]+:[a-zA-Z0-9._:%-]+\/[a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*(?:\/[a-zA-Z0-9._-]+)?$/, 160 + 'Invalid AT-URI format. Expected: at://did:method:id/collection[/rkey]' 161 + ); 162 162 163 163 /** 164 164 * Validate an issue title 165 165 * @throws {z.ZodError} if validation fails 166 166 */ 167 167 export function validateIssueTitle(title: string): string { 168 - return issueTitleSchema.parse(title); 168 + return issueTitleSchema.parse(title); 169 169 } 170 170 171 171 /** ··· 173 173 * @throws {z.ZodError} if validation fails 174 174 */ 175 175 export function validateIssueBody(body: string): string { 176 - return issueBodySchema.parse(body) ?? ''; 176 + return issueBodySchema.parse(body) ?? ''; 177 177 } 178 178 179 179 /** ··· 181 181 * Returns true/false without throwing 182 182 */ 183 183 export function isValidAtUri(uri: string): boolean { 184 - return atUriSchema.safeParse(uri).success; 184 + return atUriSchema.safeParse(uri).success; 185 185 }
+560 -568
tests/lib/issues-api.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import type { TangledApiClient } from '../../src/lib/api-client.js'; 2 3 import { 3 - closeIssue, 4 - createIssue, 5 - deleteIssue, 6 - getIssue, 7 - listIssues, 8 - updateIssue, 4 + closeIssue, 5 + createIssue, 6 + deleteIssue, 7 + getIssue, 8 + listIssues, 9 + updateIssue, 9 10 } from '../../src/lib/issues-api.js'; 10 - import type { TangledApiClient } from '../../src/lib/api-client.js'; 11 11 12 12 // Mock API client factory 13 13 const createMockClient = (authenticated = true): TangledApiClient => { 14 - const mockAgent = { 15 - com: { 16 - atproto: { 17 - repo: { 18 - createRecord: vi.fn(), 19 - listRecords: vi.fn(), 20 - getRecord: vi.fn(), 21 - putRecord: vi.fn(), 22 - deleteRecord: vi.fn(), 23 - }, 24 - }, 25 - }, 26 - }; 14 + const mockAgent = { 15 + com: { 16 + atproto: { 17 + repo: { 18 + createRecord: vi.fn(), 19 + listRecords: vi.fn(), 20 + getRecord: vi.fn(), 21 + putRecord: vi.fn(), 22 + deleteRecord: vi.fn(), 23 + }, 24 + }, 25 + }, 26 + }; 27 27 28 - return { 29 - isAuthenticated: vi.fn(async () => authenticated), 30 - getSession: vi.fn(() => 31 - authenticated 32 - ? { did: 'did:plc:test123', handle: 'test.bsky.social' } 33 - : null, 34 - ), 35 - getAgent: vi.fn(() => mockAgent), 36 - } as unknown as TangledApiClient; 28 + return { 29 + isAuthenticated: vi.fn(async () => authenticated), 30 + getSession: vi.fn(() => 31 + authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null 32 + ), 33 + getAgent: vi.fn(() => mockAgent), 34 + } as unknown as TangledApiClient; 37 35 }; 38 36 39 37 describe('createIssue', () => { 40 - let mockClient: TangledApiClient; 38 + let mockClient: TangledApiClient; 41 39 42 - beforeEach(() => { 43 - mockClient = createMockClient(true); 44 - }); 40 + beforeEach(() => { 41 + mockClient = createMockClient(true); 42 + }); 45 43 46 - it('should create an issue with all fields', async () => { 47 - const mockCreateRecord = vi.fn().mockResolvedValue({ 48 - data: { 49 - uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 50 - cid: 'cid123', 51 - }, 52 - }); 44 + it('should create an issue with all fields', async () => { 45 + const mockCreateRecord = vi.fn().mockResolvedValue({ 46 + data: { 47 + uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 48 + cid: 'cid123', 49 + }, 50 + }); 53 51 54 - vi.mocked(mockClient.getAgent).mockReturnValue({ 55 - com: { 56 - atproto: { 57 - repo: { 58 - createRecord: mockCreateRecord, 59 - }, 60 - }, 61 - }, 62 - } as never); 52 + vi.mocked(mockClient.getAgent).mockReturnValue({ 53 + com: { 54 + atproto: { 55 + repo: { 56 + createRecord: mockCreateRecord, 57 + }, 58 + }, 59 + }, 60 + } as never); 63 61 64 - const result = await createIssue({ 65 - client: mockClient, 66 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 67 - title: 'Bug: Login fails', 68 - body: 'Detailed description of the bug', 69 - }); 62 + const result = await createIssue({ 63 + client: mockClient, 64 + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 65 + title: 'Bug: Login fails', 66 + body: 'Detailed description of the bug', 67 + }); 70 68 71 - expect(result).toMatchObject({ 72 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 73 - title: 'Bug: Login fails', 74 - body: 'Detailed description of the bug', 75 - uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 76 - cid: 'cid123', 77 - author: 'did:plc:test123', 78 - }); 69 + expect(result).toMatchObject({ 70 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 71 + title: 'Bug: Login fails', 72 + body: 'Detailed description of the bug', 73 + uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 74 + cid: 'cid123', 75 + author: 'did:plc:test123', 76 + }); 79 77 80 - expect(mockCreateRecord).toHaveBeenCalledWith({ 81 - repo: 'did:plc:test123', 82 - collection: 'sh.tangled.repo.issue', 83 - record: expect.objectContaining({ 84 - $type: 'sh.tangled.repo.issue', 85 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 86 - title: 'Bug: Login fails', 87 - body: 'Detailed description of the bug', 88 - createdAt: expect.any(String), 89 - }), 90 - }); 91 - }); 78 + expect(mockCreateRecord).toHaveBeenCalledWith({ 79 + repo: 'did:plc:test123', 80 + collection: 'sh.tangled.repo.issue', 81 + record: expect.objectContaining({ 82 + $type: 'sh.tangled.repo.issue', 83 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 84 + title: 'Bug: Login fails', 85 + body: 'Detailed description of the bug', 86 + createdAt: expect.any(String), 87 + }), 88 + }); 89 + }); 92 90 93 - it('should create an issue without body', async () => { 94 - const mockCreateRecord = vi.fn().mockResolvedValue({ 95 - data: { 96 - uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 97 - cid: 'cid123', 98 - }, 99 - }); 91 + it('should create an issue without body', async () => { 92 + const mockCreateRecord = vi.fn().mockResolvedValue({ 93 + data: { 94 + uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 95 + cid: 'cid123', 96 + }, 97 + }); 100 98 101 - vi.mocked(mockClient.getAgent).mockReturnValue({ 102 - com: { 103 - atproto: { 104 - repo: { 105 - createRecord: mockCreateRecord, 106 - }, 107 - }, 108 - }, 109 - } as never); 99 + vi.mocked(mockClient.getAgent).mockReturnValue({ 100 + com: { 101 + atproto: { 102 + repo: { 103 + createRecord: mockCreateRecord, 104 + }, 105 + }, 106 + }, 107 + } as never); 110 108 111 - const result = await createIssue({ 112 - client: mockClient, 113 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 114 - title: 'Simple issue', 115 - }); 109 + const result = await createIssue({ 110 + client: mockClient, 111 + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 112 + title: 'Simple issue', 113 + }); 116 114 117 - expect(result.body).toBeUndefined(); 118 - expect(mockCreateRecord).toHaveBeenCalled(); 119 - }); 115 + expect(result.body).toBeUndefined(); 116 + expect(mockCreateRecord).toHaveBeenCalled(); 117 + }); 120 118 121 - it('should throw error when not authenticated', async () => { 122 - mockClient = createMockClient(false); 119 + it('should throw error when not authenticated', async () => { 120 + mockClient = createMockClient(false); 123 121 124 - await expect( 125 - createIssue({ 126 - client: mockClient, 127 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 128 - title: 'Test', 129 - }), 130 - ).rejects.toThrow('Must be authenticated'); 131 - }); 122 + await expect( 123 + createIssue({ 124 + client: mockClient, 125 + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 126 + title: 'Test', 127 + }) 128 + ).rejects.toThrow('Must be authenticated'); 129 + }); 132 130 133 - it('should throw error on API failure', async () => { 134 - const mockCreateRecord = vi 135 - .fn() 136 - .mockRejectedValue(new Error('API error')); 131 + it('should throw error on API failure', async () => { 132 + const mockCreateRecord = vi.fn().mockRejectedValue(new Error('API error')); 137 133 138 - vi.mocked(mockClient.getAgent).mockReturnValue({ 139 - com: { 140 - atproto: { 141 - repo: { 142 - createRecord: mockCreateRecord, 143 - }, 144 - }, 145 - }, 146 - } as never); 134 + vi.mocked(mockClient.getAgent).mockReturnValue({ 135 + com: { 136 + atproto: { 137 + repo: { 138 + createRecord: mockCreateRecord, 139 + }, 140 + }, 141 + }, 142 + } as never); 147 143 148 - await expect( 149 - createIssue({ 150 - client: mockClient, 151 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 152 - title: 'Test', 153 - }), 154 - ).rejects.toThrow('Failed to create issue: API error'); 155 - }); 144 + await expect( 145 + createIssue({ 146 + client: mockClient, 147 + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 148 + title: 'Test', 149 + }) 150 + ).rejects.toThrow('Failed to create issue: API error'); 151 + }); 156 152 }); 157 153 158 154 describe('listIssues', () => { 159 - let mockClient: TangledApiClient; 155 + let mockClient: TangledApiClient; 160 156 161 - beforeEach(() => { 162 - mockClient = createMockClient(true); 163 - }); 157 + beforeEach(() => { 158 + mockClient = createMockClient(true); 159 + }); 164 160 165 - it('should list issues for a repository', async () => { 166 - const mockListRecords = vi.fn().mockResolvedValue({ 167 - data: { 168 - records: [ 169 - { 170 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 171 - cid: 'cid1', 172 - value: { 173 - $type: 'sh.tangled.repo.issue', 174 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 175 - title: 'Issue 1', 176 - body: 'Description 1', 177 - createdAt: '2024-01-01T00:00:00.000Z', 178 - }, 179 - }, 180 - { 181 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', 182 - cid: 'cid2', 183 - value: { 184 - $type: 'sh.tangled.repo.issue', 185 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 186 - title: 'Issue 2', 187 - createdAt: '2024-01-02T00:00:00.000Z', 188 - }, 189 - }, 190 - ], 191 - cursor: undefined, 192 - }, 193 - }); 161 + it('should list issues for a repository', async () => { 162 + const mockListRecords = vi.fn().mockResolvedValue({ 163 + data: { 164 + records: [ 165 + { 166 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 167 + cid: 'cid1', 168 + value: { 169 + $type: 'sh.tangled.repo.issue', 170 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 171 + title: 'Issue 1', 172 + body: 'Description 1', 173 + createdAt: '2024-01-01T00:00:00.000Z', 174 + }, 175 + }, 176 + { 177 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', 178 + cid: 'cid2', 179 + value: { 180 + $type: 'sh.tangled.repo.issue', 181 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 182 + title: 'Issue 2', 183 + createdAt: '2024-01-02T00:00:00.000Z', 184 + }, 185 + }, 186 + ], 187 + cursor: undefined, 188 + }, 189 + }); 194 190 195 - vi.mocked(mockClient.getAgent).mockReturnValue({ 196 - com: { 197 - atproto: { 198 - repo: { 199 - listRecords: mockListRecords, 200 - }, 201 - }, 202 - }, 203 - } as never); 191 + vi.mocked(mockClient.getAgent).mockReturnValue({ 192 + com: { 193 + atproto: { 194 + repo: { 195 + listRecords: mockListRecords, 196 + }, 197 + }, 198 + }, 199 + } as never); 204 200 205 - const result = await listIssues({ 206 - client: mockClient, 207 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 208 - }); 201 + const result = await listIssues({ 202 + client: mockClient, 203 + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 204 + }); 209 205 210 - expect(result.issues).toHaveLength(2); 211 - expect(result.issues[0]).toMatchObject({ 212 - title: 'Issue 1', 213 - body: 'Description 1', 214 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 215 - }); 216 - }); 206 + expect(result.issues).toHaveLength(2); 207 + expect(result.issues[0]).toMatchObject({ 208 + title: 'Issue 1', 209 + body: 'Description 1', 210 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 211 + }); 212 + }); 217 213 218 - it('should filter issues by repository', async () => { 219 - const mockListRecords = vi.fn().mockResolvedValue({ 220 - data: { 221 - records: [ 222 - { 223 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 224 - cid: 'cid1', 225 - value: { 226 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 227 - title: 'Issue 1', 228 - createdAt: '2024-01-01T00:00:00.000Z', 229 - }, 230 - }, 231 - { 232 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', 233 - cid: 'cid2', 234 - value: { 235 - repo: 'at://did:plc:owner/sh.tangled.repo/other-repo', 236 - title: 'Issue 2', 237 - createdAt: '2024-01-02T00:00:00.000Z', 238 - }, 239 - }, 240 - ], 241 - cursor: undefined, 242 - }, 243 - }); 214 + it('should filter issues by repository', async () => { 215 + const mockListRecords = vi.fn().mockResolvedValue({ 216 + data: { 217 + records: [ 218 + { 219 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 220 + cid: 'cid1', 221 + value: { 222 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 223 + title: 'Issue 1', 224 + createdAt: '2024-01-01T00:00:00.000Z', 225 + }, 226 + }, 227 + { 228 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', 229 + cid: 'cid2', 230 + value: { 231 + repo: 'at://did:plc:owner/sh.tangled.repo/other-repo', 232 + title: 'Issue 2', 233 + createdAt: '2024-01-02T00:00:00.000Z', 234 + }, 235 + }, 236 + ], 237 + cursor: undefined, 238 + }, 239 + }); 244 240 245 - vi.mocked(mockClient.getAgent).mockReturnValue({ 246 - com: { 247 - atproto: { 248 - repo: { 249 - listRecords: mockListRecords, 250 - }, 251 - }, 252 - }, 253 - } as never); 241 + vi.mocked(mockClient.getAgent).mockReturnValue({ 242 + com: { 243 + atproto: { 244 + repo: { 245 + listRecords: mockListRecords, 246 + }, 247 + }, 248 + }, 249 + } as never); 254 250 255 - const result = await listIssues({ 256 - client: mockClient, 257 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 258 - }); 251 + const result = await listIssues({ 252 + client: mockClient, 253 + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 254 + }); 259 255 260 - // Should only include issue from my-repo, not other-repo 261 - expect(result.issues).toHaveLength(1); 262 - expect(result.issues[0].title).toBe('Issue 1'); 263 - }); 256 + // Should only include issue from my-repo, not other-repo 257 + expect(result.issues).toHaveLength(1); 258 + expect(result.issues[0].title).toBe('Issue 1'); 259 + }); 264 260 265 - it('should return empty array when no issues found', async () => { 266 - const mockListRecords = vi.fn().mockResolvedValue({ 267 - data: { 268 - records: [], 269 - cursor: undefined, 270 - }, 271 - }); 261 + it('should return empty array when no issues found', async () => { 262 + const mockListRecords = vi.fn().mockResolvedValue({ 263 + data: { 264 + records: [], 265 + cursor: undefined, 266 + }, 267 + }); 272 268 273 - vi.mocked(mockClient.getAgent).mockReturnValue({ 274 - com: { 275 - atproto: { 276 - repo: { 277 - listRecords: mockListRecords, 278 - }, 279 - }, 280 - }, 281 - } as never); 269 + vi.mocked(mockClient.getAgent).mockReturnValue({ 270 + com: { 271 + atproto: { 272 + repo: { 273 + listRecords: mockListRecords, 274 + }, 275 + }, 276 + }, 277 + } as never); 282 278 283 - const result = await listIssues({ 284 - client: mockClient, 285 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 286 - }); 279 + const result = await listIssues({ 280 + client: mockClient, 281 + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 282 + }); 287 283 288 - expect(result.issues).toEqual([]); 289 - }); 284 + expect(result.issues).toEqual([]); 285 + }); 290 286 291 - it('should throw error when not authenticated', async () => { 292 - mockClient = createMockClient(false); 287 + it('should throw error when not authenticated', async () => { 288 + mockClient = createMockClient(false); 293 289 294 - await expect( 295 - listIssues({ 296 - client: mockClient, 297 - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 298 - }), 299 - ).rejects.toThrow('Must be authenticated'); 300 - }); 290 + await expect( 291 + listIssues({ 292 + client: mockClient, 293 + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 294 + }) 295 + ).rejects.toThrow('Must be authenticated'); 296 + }); 301 297 302 - it('should throw error for invalid repo URI', async () => { 303 - await expect( 304 - listIssues({ 305 - client: mockClient, 306 - repoAtUri: 'invalid-uri', 307 - }), 308 - ).rejects.toThrow('Invalid repository AT-URI'); 309 - }); 298 + it('should throw error for invalid repo URI', async () => { 299 + await expect( 300 + listIssues({ 301 + client: mockClient, 302 + repoAtUri: 'invalid-uri', 303 + }) 304 + ).rejects.toThrow('Invalid repository AT-URI'); 305 + }); 310 306 }); 311 307 312 308 describe('getIssue', () => { 313 - let mockClient: TangledApiClient; 309 + let mockClient: TangledApiClient; 314 310 315 - beforeEach(() => { 316 - mockClient = createMockClient(true); 317 - }); 311 + beforeEach(() => { 312 + mockClient = createMockClient(true); 313 + }); 318 314 319 - it('should get a specific issue', async () => { 320 - const mockGetRecord = vi.fn().mockResolvedValue({ 321 - data: { 322 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 323 - cid: 'cid1', 324 - value: { 325 - $type: 'sh.tangled.repo.issue', 326 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 327 - title: 'Test Issue', 328 - body: 'Test Description', 329 - createdAt: '2024-01-01T00:00:00.000Z', 330 - }, 331 - }, 332 - }); 315 + it('should get a specific issue', async () => { 316 + const mockGetRecord = vi.fn().mockResolvedValue({ 317 + data: { 318 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 319 + cid: 'cid1', 320 + value: { 321 + $type: 'sh.tangled.repo.issue', 322 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 323 + title: 'Test Issue', 324 + body: 'Test Description', 325 + createdAt: '2024-01-01T00:00:00.000Z', 326 + }, 327 + }, 328 + }); 333 329 334 - vi.mocked(mockClient.getAgent).mockReturnValue({ 335 - com: { 336 - atproto: { 337 - repo: { 338 - getRecord: mockGetRecord, 339 - }, 340 - }, 341 - }, 342 - } as never); 330 + vi.mocked(mockClient.getAgent).mockReturnValue({ 331 + com: { 332 + atproto: { 333 + repo: { 334 + getRecord: mockGetRecord, 335 + }, 336 + }, 337 + }, 338 + } as never); 343 339 344 - const result = await getIssue({ 345 - client: mockClient, 346 - issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 347 - }); 340 + const result = await getIssue({ 341 + client: mockClient, 342 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 343 + }); 348 344 349 - expect(result).toMatchObject({ 350 - title: 'Test Issue', 351 - body: 'Test Description', 352 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 353 - cid: 'cid1', 354 - }); 345 + expect(result).toMatchObject({ 346 + title: 'Test Issue', 347 + body: 'Test Description', 348 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 349 + cid: 'cid1', 350 + }); 355 351 356 - expect(mockGetRecord).toHaveBeenCalledWith({ 357 - repo: 'did:plc:owner', 358 - collection: 'sh.tangled.repo.issue', 359 - rkey: 'issue1', 360 - }); 361 - }); 352 + expect(mockGetRecord).toHaveBeenCalledWith({ 353 + repo: 'did:plc:owner', 354 + collection: 'sh.tangled.repo.issue', 355 + rkey: 'issue1', 356 + }); 357 + }); 362 358 363 - it('should throw error when issue not found', async () => { 364 - const mockGetRecord = vi 365 - .fn() 366 - .mockRejectedValue(new Error('Record not found')); 359 + it('should throw error when issue not found', async () => { 360 + const mockGetRecord = vi.fn().mockRejectedValue(new Error('Record not found')); 367 361 368 - vi.mocked(mockClient.getAgent).mockReturnValue({ 369 - com: { 370 - atproto: { 371 - repo: { 372 - getRecord: mockGetRecord, 373 - }, 374 - }, 375 - }, 376 - } as never); 362 + vi.mocked(mockClient.getAgent).mockReturnValue({ 363 + com: { 364 + atproto: { 365 + repo: { 366 + getRecord: mockGetRecord, 367 + }, 368 + }, 369 + }, 370 + } as never); 377 371 378 - await expect( 379 - getIssue({ 380 - client: mockClient, 381 - issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', 382 - }), 383 - ).rejects.toThrow('Issue not found'); 384 - }); 372 + await expect( 373 + getIssue({ 374 + client: mockClient, 375 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', 376 + }) 377 + ).rejects.toThrow('Issue not found'); 378 + }); 385 379 386 - it('should throw error for invalid issue URI', async () => { 387 - await expect( 388 - getIssue({ 389 - client: mockClient, 390 - issueUri: 'invalid-uri', 391 - }), 392 - ).rejects.toThrow('Invalid issue AT-URI'); 393 - }); 380 + it('should throw error for invalid issue URI', async () => { 381 + await expect( 382 + getIssue({ 383 + client: mockClient, 384 + issueUri: 'invalid-uri', 385 + }) 386 + ).rejects.toThrow('Invalid issue AT-URI'); 387 + }); 394 388 395 - it('should throw error when not authenticated', async () => { 396 - mockClient = createMockClient(false); 389 + it('should throw error when not authenticated', async () => { 390 + mockClient = createMockClient(false); 397 391 398 - await expect( 399 - getIssue({ 400 - client: mockClient, 401 - issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 402 - }), 403 - ).rejects.toThrow('Must be authenticated'); 404 - }); 392 + await expect( 393 + getIssue({ 394 + client: mockClient, 395 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 396 + }) 397 + ).rejects.toThrow('Must be authenticated'); 398 + }); 405 399 }); 406 400 407 401 describe('updateIssue', () => { 408 - let mockClient: TangledApiClient; 402 + let mockClient: TangledApiClient; 409 403 410 - beforeEach(() => { 411 - mockClient = createMockClient(true); 412 - }); 404 + beforeEach(() => { 405 + mockClient = createMockClient(true); 406 + }); 413 407 414 - it('should update issue title', async () => { 415 - const mockGetRecord = vi.fn().mockResolvedValue({ 416 - data: { 417 - uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 418 - cid: 'old-cid', 419 - value: { 420 - repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', 421 - title: 'Old Title', 422 - body: 'Original body', 423 - createdAt: '2024-01-01T00:00:00.000Z', 424 - }, 425 - }, 426 - }); 408 + it('should update issue title', async () => { 409 + const mockGetRecord = vi.fn().mockResolvedValue({ 410 + data: { 411 + uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 412 + cid: 'old-cid', 413 + value: { 414 + repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', 415 + title: 'Old Title', 416 + body: 'Original body', 417 + createdAt: '2024-01-01T00:00:00.000Z', 418 + }, 419 + }, 420 + }); 427 421 428 - const mockPutRecord = vi.fn().mockResolvedValue({ 429 - data: { 430 - uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 431 - cid: 'new-cid', 432 - }, 433 - }); 422 + const mockPutRecord = vi.fn().mockResolvedValue({ 423 + data: { 424 + uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 425 + cid: 'new-cid', 426 + }, 427 + }); 434 428 435 - vi.mocked(mockClient.getAgent).mockReturnValue({ 436 - com: { 437 - atproto: { 438 - repo: { 439 - getRecord: mockGetRecord, 440 - putRecord: mockPutRecord, 441 - }, 442 - }, 443 - }, 444 - } as never); 429 + vi.mocked(mockClient.getAgent).mockReturnValue({ 430 + com: { 431 + atproto: { 432 + repo: { 433 + getRecord: mockGetRecord, 434 + putRecord: mockPutRecord, 435 + }, 436 + }, 437 + }, 438 + } as never); 445 439 446 - const result = await updateIssue({ 447 - client: mockClient, 448 - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 449 - title: 'New Title', 450 - }); 440 + const result = await updateIssue({ 441 + client: mockClient, 442 + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 443 + title: 'New Title', 444 + }); 451 445 452 - expect(result.title).toBe('New Title'); 453 - expect(result.body).toBe('Original body'); // Body unchanged 446 + expect(result.title).toBe('New Title'); 447 + expect(result.body).toBe('Original body'); // Body unchanged 454 448 455 - expect(mockPutRecord).toHaveBeenCalledWith({ 456 - repo: 'did:plc:test123', 457 - collection: 'sh.tangled.repo.issue', 458 - rkey: 'issue1', 459 - record: expect.objectContaining({ 460 - title: 'New Title', 461 - body: 'Original body', 462 - }), 463 - swapRecord: 'old-cid', 464 - }); 465 - }); 449 + expect(mockPutRecord).toHaveBeenCalledWith({ 450 + repo: 'did:plc:test123', 451 + collection: 'sh.tangled.repo.issue', 452 + rkey: 'issue1', 453 + record: expect.objectContaining({ 454 + title: 'New Title', 455 + body: 'Original body', 456 + }), 457 + swapRecord: 'old-cid', 458 + }); 459 + }); 466 460 467 - it('should update issue body', async () => { 468 - const mockGetRecord = vi.fn().mockResolvedValue({ 469 - data: { 470 - uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 471 - cid: 'old-cid', 472 - value: { 473 - repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', 474 - title: 'Title', 475 - body: 'Old body', 476 - createdAt: '2024-01-01T00:00:00.000Z', 477 - }, 478 - }, 479 - }); 461 + it('should update issue body', async () => { 462 + const mockGetRecord = vi.fn().mockResolvedValue({ 463 + data: { 464 + uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 465 + cid: 'old-cid', 466 + value: { 467 + repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', 468 + title: 'Title', 469 + body: 'Old body', 470 + createdAt: '2024-01-01T00:00:00.000Z', 471 + }, 472 + }, 473 + }); 480 474 481 - const mockPutRecord = vi.fn().mockResolvedValue({ 482 - data: { 483 - cid: 'new-cid', 484 - }, 485 - }); 475 + const mockPutRecord = vi.fn().mockResolvedValue({ 476 + data: { 477 + cid: 'new-cid', 478 + }, 479 + }); 486 480 487 - vi.mocked(mockClient.getAgent).mockReturnValue({ 488 - com: { 489 - atproto: { 490 - repo: { 491 - getRecord: mockGetRecord, 492 - putRecord: mockPutRecord, 493 - }, 494 - }, 495 - }, 496 - } as never); 481 + vi.mocked(mockClient.getAgent).mockReturnValue({ 482 + com: { 483 + atproto: { 484 + repo: { 485 + getRecord: mockGetRecord, 486 + putRecord: mockPutRecord, 487 + }, 488 + }, 489 + }, 490 + } as never); 497 491 498 - const result = await updateIssue({ 499 - client: mockClient, 500 - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 501 - body: 'New body', 502 - }); 492 + const result = await updateIssue({ 493 + client: mockClient, 494 + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 495 + body: 'New body', 496 + }); 503 497 504 - expect(result.title).toBe('Title'); // Title unchanged 505 - expect(result.body).toBe('New body'); 506 - }); 498 + expect(result.title).toBe('Title'); // Title unchanged 499 + expect(result.body).toBe('New body'); 500 + }); 507 501 508 - it('should throw error when updating issue not owned by user', async () => { 509 - await expect( 510 - updateIssue({ 511 - client: mockClient, 512 - issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', 513 - title: 'New Title', 514 - }), 515 - ).rejects.toThrow('Cannot update issue: you are not the author'); 516 - }); 502 + it('should throw error when updating issue not owned by user', async () => { 503 + await expect( 504 + updateIssue({ 505 + client: mockClient, 506 + issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', 507 + title: 'New Title', 508 + }) 509 + ).rejects.toThrow('Cannot update issue: you are not the author'); 510 + }); 517 511 518 - it('should throw error when not authenticated', async () => { 519 - mockClient = createMockClient(false); 512 + it('should throw error when not authenticated', async () => { 513 + mockClient = createMockClient(false); 520 514 521 - await expect( 522 - updateIssue({ 523 - client: mockClient, 524 - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 525 - title: 'New Title', 526 - }), 527 - ).rejects.toThrow('Must be authenticated'); 528 - }); 515 + await expect( 516 + updateIssue({ 517 + client: mockClient, 518 + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 519 + title: 'New Title', 520 + }) 521 + ).rejects.toThrow('Must be authenticated'); 522 + }); 529 523 }); 530 524 531 525 describe('closeIssue', () => { 532 - let mockClient: TangledApiClient; 526 + let mockClient: TangledApiClient; 533 527 534 - beforeEach(() => { 535 - mockClient = createMockClient(true); 536 - }); 528 + beforeEach(() => { 529 + mockClient = createMockClient(true); 530 + }); 537 531 538 - it('should close an issue', async () => { 539 - const mockGetRecord = vi.fn().mockResolvedValue({ 540 - data: { 541 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 542 - cid: 'cid1', 543 - value: { 544 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 545 - title: 'Test Issue', 546 - createdAt: '2024-01-01T00:00:00.000Z', 547 - }, 548 - }, 549 - }); 532 + it('should close an issue', async () => { 533 + const mockGetRecord = vi.fn().mockResolvedValue({ 534 + data: { 535 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 536 + cid: 'cid1', 537 + value: { 538 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 539 + title: 'Test Issue', 540 + createdAt: '2024-01-01T00:00:00.000Z', 541 + }, 542 + }, 543 + }); 550 544 551 - const mockCreateRecord = vi.fn().mockResolvedValue({ 552 - data: { 553 - uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', 554 - cid: 'state-cid', 555 - }, 556 - }); 545 + const mockCreateRecord = vi.fn().mockResolvedValue({ 546 + data: { 547 + uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', 548 + cid: 'state-cid', 549 + }, 550 + }); 557 551 558 - vi.mocked(mockClient.getAgent).mockReturnValue({ 559 - com: { 560 - atproto: { 561 - repo: { 562 - getRecord: mockGetRecord, 563 - createRecord: mockCreateRecord, 564 - }, 565 - }, 566 - }, 567 - } as never); 552 + vi.mocked(mockClient.getAgent).mockReturnValue({ 553 + com: { 554 + atproto: { 555 + repo: { 556 + getRecord: mockGetRecord, 557 + createRecord: mockCreateRecord, 558 + }, 559 + }, 560 + }, 561 + } as never); 568 562 569 - await closeIssue({ 570 - client: mockClient, 571 - issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 572 - }); 563 + await closeIssue({ 564 + client: mockClient, 565 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 566 + }); 573 567 574 - expect(mockCreateRecord).toHaveBeenCalledWith({ 575 - repo: 'did:plc:test123', 576 - collection: 'sh.tangled.repo.issue.state', 577 - record: { 578 - $type: 'sh.tangled.repo.issue.state', 579 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 580 - state: 'sh.tangled.repo.issue.state.closed', 581 - }, 582 - }); 583 - }); 568 + expect(mockCreateRecord).toHaveBeenCalledWith({ 569 + repo: 'did:plc:test123', 570 + collection: 'sh.tangled.repo.issue.state', 571 + record: { 572 + $type: 'sh.tangled.repo.issue.state', 573 + issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 574 + state: 'sh.tangled.repo.issue.state.closed', 575 + }, 576 + }); 577 + }); 584 578 585 - it('should throw error when not authenticated', async () => { 586 - mockClient = createMockClient(false); 579 + it('should throw error when not authenticated', async () => { 580 + mockClient = createMockClient(false); 587 581 588 - await expect( 589 - closeIssue({ 590 - client: mockClient, 591 - issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 592 - }), 593 - ).rejects.toThrow('Must be authenticated'); 594 - }); 582 + await expect( 583 + closeIssue({ 584 + client: mockClient, 585 + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 586 + }) 587 + ).rejects.toThrow('Must be authenticated'); 588 + }); 595 589 }); 596 590 597 591 describe('deleteIssue', () => { 598 - let mockClient: TangledApiClient; 592 + let mockClient: TangledApiClient; 599 593 600 - beforeEach(() => { 601 - mockClient = createMockClient(true); 602 - }); 594 + beforeEach(() => { 595 + mockClient = createMockClient(true); 596 + }); 603 597 604 - it('should delete an issue', async () => { 605 - const mockDeleteRecord = vi.fn().mockResolvedValue({}); 598 + it('should delete an issue', async () => { 599 + const mockDeleteRecord = vi.fn().mockResolvedValue({}); 606 600 607 - vi.mocked(mockClient.getAgent).mockReturnValue({ 608 - com: { 609 - atproto: { 610 - repo: { 611 - deleteRecord: mockDeleteRecord, 612 - }, 613 - }, 614 - }, 615 - } as never); 601 + vi.mocked(mockClient.getAgent).mockReturnValue({ 602 + com: { 603 + atproto: { 604 + repo: { 605 + deleteRecord: mockDeleteRecord, 606 + }, 607 + }, 608 + }, 609 + } as never); 616 610 617 - await deleteIssue({ 618 - client: mockClient, 619 - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 620 - }); 611 + await deleteIssue({ 612 + client: mockClient, 613 + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 614 + }); 621 615 622 - expect(mockDeleteRecord).toHaveBeenCalledWith({ 623 - repo: 'did:plc:test123', 624 - collection: 'sh.tangled.repo.issue', 625 - rkey: 'issue1', 626 - }); 627 - }); 616 + expect(mockDeleteRecord).toHaveBeenCalledWith({ 617 + repo: 'did:plc:test123', 618 + collection: 'sh.tangled.repo.issue', 619 + rkey: 'issue1', 620 + }); 621 + }); 628 622 629 - it('should throw error when deleting issue not owned by user', async () => { 630 - await expect( 631 - deleteIssue({ 632 - client: mockClient, 633 - issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', 634 - }), 635 - ).rejects.toThrow('Cannot delete issue: you are not the author'); 636 - }); 623 + it('should throw error when deleting issue not owned by user', async () => { 624 + await expect( 625 + deleteIssue({ 626 + client: mockClient, 627 + issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', 628 + }) 629 + ).rejects.toThrow('Cannot delete issue: you are not the author'); 630 + }); 637 631 638 - it('should throw error when issue not found', async () => { 639 - const mockDeleteRecord = vi 640 - .fn() 641 - .mockRejectedValue(new Error('Record not found')); 632 + it('should throw error when issue not found', async () => { 633 + const mockDeleteRecord = vi.fn().mockRejectedValue(new Error('Record not found')); 642 634 643 - vi.mocked(mockClient.getAgent).mockReturnValue({ 644 - com: { 645 - atproto: { 646 - repo: { 647 - deleteRecord: mockDeleteRecord, 648 - }, 649 - }, 650 - }, 651 - } as never); 635 + vi.mocked(mockClient.getAgent).mockReturnValue({ 636 + com: { 637 + atproto: { 638 + repo: { 639 + deleteRecord: mockDeleteRecord, 640 + }, 641 + }, 642 + }, 643 + } as never); 652 644 653 - await expect( 654 - deleteIssue({ 655 - client: mockClient, 656 - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/nonexistent', 657 - }), 658 - ).rejects.toThrow('Issue not found'); 659 - }); 645 + await expect( 646 + deleteIssue({ 647 + client: mockClient, 648 + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/nonexistent', 649 + }) 650 + ).rejects.toThrow('Issue not found'); 651 + }); 660 652 661 - it('should throw error when not authenticated', async () => { 662 - mockClient = createMockClient(false); 653 + it('should throw error when not authenticated', async () => { 654 + mockClient = createMockClient(false); 663 655 664 - await expect( 665 - deleteIssue({ 666 - client: mockClient, 667 - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 668 - }), 669 - ).rejects.toThrow('Must be authenticated'); 670 - }); 656 + await expect( 657 + deleteIssue({ 658 + client: mockClient, 659 + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 660 + }) 661 + ).rejects.toThrow('Must be authenticated'); 662 + }); 671 663 });
+166 -190
tests/utils/at-uri.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 - import { 3 - buildRepoAtUri, 4 - parseAtUri, 5 - resolveHandleToDid, 6 - } from '../../src/utils/at-uri.js'; 7 2 import type { TangledApiClient } from '../../src/lib/api-client.js'; 3 + import { buildRepoAtUri, parseAtUri, resolveHandleToDid } from '../../src/utils/at-uri.js'; 8 4 9 5 // Mock API client 10 6 const createMockClient = (): TangledApiClient => { 11 - return { 12 - getAgent: vi.fn(() => ({ 13 - com: { 14 - atproto: { 15 - identity: { 16 - resolveHandle: vi.fn(), 17 - }, 18 - }, 19 - }, 20 - })), 21 - } as unknown as TangledApiClient; 7 + return { 8 + getAgent: vi.fn(() => ({ 9 + com: { 10 + atproto: { 11 + identity: { 12 + resolveHandle: vi.fn(), 13 + }, 14 + }, 15 + }, 16 + })), 17 + } as unknown as TangledApiClient; 22 18 }; 23 19 24 20 describe('parseAtUri', () => { 25 - it('should parse AT-URI with rkey', () => { 26 - const uri = 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789'; 27 - const result = parseAtUri(uri); 21 + it('should parse AT-URI with rkey', () => { 22 + const uri = 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789'; 23 + const result = parseAtUri(uri); 28 24 29 - expect(result).toEqual({ 30 - did: 'did:plc:abc123', 31 - collection: 'sh.tangled.repo.issue', 32 - rkey: 'xyz789', 33 - }); 34 - }); 25 + expect(result).toEqual({ 26 + did: 'did:plc:abc123', 27 + collection: 'sh.tangled.repo.issue', 28 + rkey: 'xyz789', 29 + }); 30 + }); 35 31 36 - it('should parse AT-URI without rkey', () => { 37 - const uri = 'at://did:plc:abc123/sh.tangled.repo'; 38 - const result = parseAtUri(uri); 32 + it('should parse AT-URI without rkey', () => { 33 + const uri = 'at://did:plc:abc123/sh.tangled.repo'; 34 + const result = parseAtUri(uri); 39 35 40 - expect(result).toEqual({ 41 - did: 'did:plc:abc123', 42 - collection: 'sh.tangled.repo', 43 - }); 44 - }); 36 + expect(result).toEqual({ 37 + did: 'did:plc:abc123', 38 + collection: 'sh.tangled.repo', 39 + }); 40 + }); 45 41 46 - it('should parse AT-URI with nested collection', () => { 47 - const uri = 'at://did:plc:abc123/sh.tangled.repo.issue.state/xyz'; 48 - const result = parseAtUri(uri); 42 + it('should parse AT-URI with nested collection', () => { 43 + const uri = 'at://did:plc:abc123/sh.tangled.repo.issue.state/xyz'; 44 + const result = parseAtUri(uri); 49 45 50 - expect(result).toEqual({ 51 - did: 'did:plc:abc123', 52 - collection: 'sh.tangled.repo.issue.state', 53 - rkey: 'xyz', 54 - }); 55 - }); 46 + expect(result).toEqual({ 47 + did: 'did:plc:abc123', 48 + collection: 'sh.tangled.repo.issue.state', 49 + rkey: 'xyz', 50 + }); 51 + }); 56 52 57 - it('should return null for invalid URI', () => { 58 - expect(parseAtUri('not-a-uri')).toBeNull(); 59 - expect(parseAtUri('http://example.com')).toBeNull(); 60 - expect(parseAtUri('at://invalid-did/collection')).toBeNull(); 61 - expect(parseAtUri('')).toBeNull(); 62 - }); 53 + it('should return null for invalid URI', () => { 54 + expect(parseAtUri('not-a-uri')).toBeNull(); 55 + expect(parseAtUri('http://example.com')).toBeNull(); 56 + expect(parseAtUri('at://invalid-did/collection')).toBeNull(); 57 + expect(parseAtUri('')).toBeNull(); 58 + }); 63 59 64 - it('should handle DIDs with various characters', () => { 65 - const uri = 'at://did:web:example.com/collection/rkey'; 66 - const result = parseAtUri(uri); 60 + it('should handle DIDs with various characters', () => { 61 + const uri = 'at://did:web:example.com/collection/rkey'; 62 + const result = parseAtUri(uri); 67 63 68 - expect(result).toEqual({ 69 - did: 'did:web:example.com', 70 - collection: 'collection', 71 - rkey: 'rkey', 72 - }); 73 - }); 64 + expect(result).toEqual({ 65 + did: 'did:web:example.com', 66 + collection: 'collection', 67 + rkey: 'rkey', 68 + }); 69 + }); 74 70 }); 75 71 76 72 describe('resolveHandleToDid', () => { 77 - let mockClient: TangledApiClient; 73 + let mockClient: TangledApiClient; 78 74 79 - beforeEach(() => { 80 - mockClient = createMockClient(); 81 - }); 75 + beforeEach(() => { 76 + mockClient = createMockClient(); 77 + }); 82 78 83 - it('should resolve handle to DID', async () => { 84 - const mockResolve = vi.fn().mockResolvedValue({ 85 - data: { did: 'did:plc:abc123' }, 86 - }); 79 + it('should resolve handle to DID', async () => { 80 + const mockResolve = vi.fn().mockResolvedValue({ 81 + data: { did: 'did:plc:abc123' }, 82 + }); 87 83 88 - vi.mocked(mockClient.getAgent).mockReturnValue({ 89 - com: { 90 - atproto: { 91 - identity: { 92 - resolveHandle: mockResolve, 93 - }, 94 - }, 95 - }, 96 - } as never); 84 + vi.mocked(mockClient.getAgent).mockReturnValue({ 85 + com: { 86 + atproto: { 87 + identity: { 88 + resolveHandle: mockResolve, 89 + }, 90 + }, 91 + }, 92 + } as never); 97 93 98 - const result = await resolveHandleToDid('mark.bsky.social', mockClient); 94 + const result = await resolveHandleToDid('mark.bsky.social', mockClient); 99 95 100 - expect(result).toBe('did:plc:abc123'); 101 - expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); 102 - }); 96 + expect(result).toBe('did:plc:abc123'); 97 + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); 98 + }); 103 99 104 - it('should strip leading @ from handle', async () => { 105 - const mockResolve = vi.fn().mockResolvedValue({ 106 - data: { did: 'did:plc:abc123' }, 107 - }); 100 + it('should strip leading @ from handle', async () => { 101 + const mockResolve = vi.fn().mockResolvedValue({ 102 + data: { did: 'did:plc:abc123' }, 103 + }); 108 104 109 - vi.mocked(mockClient.getAgent).mockReturnValue({ 110 - com: { 111 - atproto: { 112 - identity: { 113 - resolveHandle: mockResolve, 114 - }, 115 - }, 116 - }, 117 - } as never); 105 + vi.mocked(mockClient.getAgent).mockReturnValue({ 106 + com: { 107 + atproto: { 108 + identity: { 109 + resolveHandle: mockResolve, 110 + }, 111 + }, 112 + }, 113 + } as never); 118 114 119 - await resolveHandleToDid('@mark.bsky.social', mockClient); 115 + await resolveHandleToDid('@mark.bsky.social', mockClient); 120 116 121 - expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); 122 - }); 117 + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); 118 + }); 123 119 124 - it('should throw error when handle not found', async () => { 125 - const mockResolve = vi.fn().mockResolvedValue({ 126 - data: { did: null }, 127 - }); 120 + it('should throw error when handle not found', async () => { 121 + const mockResolve = vi.fn().mockResolvedValue({ 122 + data: { did: null }, 123 + }); 128 124 129 - vi.mocked(mockClient.getAgent).mockReturnValue({ 130 - com: { 131 - atproto: { 132 - identity: { 133 - resolveHandle: mockResolve, 134 - }, 135 - }, 136 - }, 137 - } as never); 125 + vi.mocked(mockClient.getAgent).mockReturnValue({ 126 + com: { 127 + atproto: { 128 + identity: { 129 + resolveHandle: mockResolve, 130 + }, 131 + }, 132 + }, 133 + } as never); 138 134 139 - await expect( 140 - resolveHandleToDid('nonexistent.bsky.social', mockClient), 141 - ).rejects.toThrow('No DID found for handle: nonexistent.bsky.social'); 142 - }); 135 + await expect(resolveHandleToDid('nonexistent.bsky.social', mockClient)).rejects.toThrow( 136 + 'No DID found for handle: nonexistent.bsky.social' 137 + ); 138 + }); 143 139 144 - it('should throw error on network failure', async () => { 145 - const mockResolve = vi 146 - .fn() 147 - .mockRejectedValue(new Error('Network error')); 140 + it('should throw error on network failure', async () => { 141 + const mockResolve = vi.fn().mockRejectedValue(new Error('Network error')); 148 142 149 - vi.mocked(mockClient.getAgent).mockReturnValue({ 150 - com: { 151 - atproto: { 152 - identity: { 153 - resolveHandle: mockResolve, 154 - }, 155 - }, 156 - }, 157 - } as never); 143 + vi.mocked(mockClient.getAgent).mockReturnValue({ 144 + com: { 145 + atproto: { 146 + identity: { 147 + resolveHandle: mockResolve, 148 + }, 149 + }, 150 + }, 151 + } as never); 158 152 159 - await expect( 160 - resolveHandleToDid('mark.bsky.social', mockClient), 161 - ).rejects.toThrow( 162 - "Failed to resolve handle 'mark.bsky.social': Network error", 163 - ); 164 - }); 153 + await expect(resolveHandleToDid('mark.bsky.social', mockClient)).rejects.toThrow( 154 + "Failed to resolve handle 'mark.bsky.social': Network error" 155 + ); 156 + }); 165 157 }); 166 158 167 159 describe('buildRepoAtUri', () => { 168 - let mockClient: TangledApiClient; 160 + let mockClient: TangledApiClient; 169 161 170 - beforeEach(() => { 171 - mockClient = createMockClient(); 172 - }); 162 + beforeEach(() => { 163 + mockClient = createMockClient(); 164 + }); 173 165 174 - it('should build AT-URI from DID', async () => { 175 - const result = await buildRepoAtUri( 176 - 'did:plc:abc123', 177 - 'my-repo', 178 - mockClient, 179 - ); 166 + it('should build AT-URI from DID', async () => { 167 + const result = await buildRepoAtUri('did:plc:abc123', 'my-repo', mockClient); 180 168 181 - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); 182 - }); 169 + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); 170 + }); 183 171 184 - it('should build AT-URI from handle', async () => { 185 - const mockResolve = vi.fn().mockResolvedValue({ 186 - data: { did: 'did:plc:abc123' }, 187 - }); 172 + it('should build AT-URI from handle', async () => { 173 + const mockResolve = vi.fn().mockResolvedValue({ 174 + data: { did: 'did:plc:abc123' }, 175 + }); 188 176 189 - vi.mocked(mockClient.getAgent).mockReturnValue({ 190 - com: { 191 - atproto: { 192 - identity: { 193 - resolveHandle: mockResolve, 194 - }, 195 - }, 196 - }, 197 - } as never); 177 + vi.mocked(mockClient.getAgent).mockReturnValue({ 178 + com: { 179 + atproto: { 180 + identity: { 181 + resolveHandle: mockResolve, 182 + }, 183 + }, 184 + }, 185 + } as never); 198 186 199 - const result = await buildRepoAtUri( 200 - 'mark.bsky.social', 201 - 'my-repo', 202 - mockClient, 203 - ); 187 + const result = await buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient); 204 188 205 - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); 206 - expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); 207 - }); 189 + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); 190 + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); 191 + }); 208 192 209 - it('should handle repository names with special characters', async () => { 210 - const result = await buildRepoAtUri( 211 - 'did:plc:abc123', 212 - 'repo-name_123', 213 - mockClient, 214 - ); 193 + it('should handle repository names with special characters', async () => { 194 + const result = await buildRepoAtUri('did:plc:abc123', 'repo-name_123', mockClient); 215 195 216 - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/repo-name_123'); 217 - }); 196 + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/repo-name_123'); 197 + }); 218 198 219 - it('should throw error when handle resolution fails', async () => { 220 - const mockResolve = vi 221 - .fn() 222 - .mockRejectedValue(new Error('Resolution failed')); 199 + it('should throw error when handle resolution fails', async () => { 200 + const mockResolve = vi.fn().mockRejectedValue(new Error('Resolution failed')); 223 201 224 - vi.mocked(mockClient.getAgent).mockReturnValue({ 225 - com: { 226 - atproto: { 227 - identity: { 228 - resolveHandle: mockResolve, 229 - }, 230 - }, 231 - }, 232 - } as never); 202 + vi.mocked(mockClient.getAgent).mockReturnValue({ 203 + com: { 204 + atproto: { 205 + identity: { 206 + resolveHandle: mockResolve, 207 + }, 208 + }, 209 + }, 210 + } as never); 233 211 234 - await expect( 235 - buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient), 236 - ).rejects.toThrow( 237 - "Failed to resolve handle 'mark.bsky.social': Resolution failed", 238 - ); 239 - }); 212 + await expect(buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient)).rejects.toThrow( 213 + "Failed to resolve handle 'mark.bsky.social': Resolution failed" 214 + ); 215 + }); 240 216 });
+25 -24
tests/utils/auth-helpers.test.ts
··· 1 1 import { describe, expect, it, vi } from 'vitest'; 2 - import { requireAuth } from '../../src/utils/auth-helpers.js'; 3 2 import type { TangledApiClient } from '../../src/lib/api-client.js'; 3 + import { requireAuth } from '../../src/utils/auth-helpers.js'; 4 4 5 5 // Mock API client factory 6 - const createMockClient = (authenticated: boolean, session: { did: string; handle: string } | null): TangledApiClient => { 7 - return { 8 - isAuthenticated: vi.fn(async () => authenticated), 9 - getSession: vi.fn(() => session), 10 - } as unknown as TangledApiClient; 6 + const createMockClient = ( 7 + authenticated: boolean, 8 + session: { did: string; handle: string } | null 9 + ): TangledApiClient => { 10 + return { 11 + isAuthenticated: vi.fn(async () => authenticated), 12 + getSession: vi.fn(() => session), 13 + } as unknown as TangledApiClient; 11 14 }; 12 15 13 16 describe('requireAuth', () => { 14 - it('should return session when authenticated', async () => { 15 - const mockSession = { did: 'did:plc:test123', handle: 'test.bsky.social' }; 16 - const mockClient = createMockClient(true, mockSession); 17 + it('should return session when authenticated', async () => { 18 + const mockSession = { did: 'did:plc:test123', handle: 'test.bsky.social' }; 19 + const mockClient = createMockClient(true, mockSession); 17 20 18 - const result = await requireAuth(mockClient); 21 + const result = await requireAuth(mockClient); 19 22 20 - expect(result).toEqual(mockSession); 21 - }); 23 + expect(result).toEqual(mockSession); 24 + }); 22 25 23 - it('should throw error when not authenticated', async () => { 24 - const mockClient = createMockClient(false, null); 26 + it('should throw error when not authenticated', async () => { 27 + const mockClient = createMockClient(false, null); 25 28 26 - await expect(requireAuth(mockClient)).rejects.toThrow( 27 - 'Must be authenticated. Run "tangled auth login" first.', 28 - ); 29 - }); 29 + await expect(requireAuth(mockClient)).rejects.toThrow( 30 + 'Must be authenticated. Run "tangled auth login" first.' 31 + ); 32 + }); 30 33 31 - it('should throw error when authenticated but no session', async () => { 32 - const mockClient = createMockClient(true, null); 34 + it('should throw error when authenticated but no session', async () => { 35 + const mockClient = createMockClient(true, null); 33 36 34 - await expect(requireAuth(mockClient)).rejects.toThrow( 35 - 'No active session found', 36 - ); 37 - }); 37 + await expect(requireAuth(mockClient)).rejects.toThrow('No active session found'); 38 + }); 38 39 });
+93 -95
tests/utils/body-input.test.ts
··· 5 5 import { readBodyInput } from '../../src/utils/body-input.js'; 6 6 7 7 describe('readBodyInput', () => { 8 - describe('direct string input', () => { 9 - it('should return body string when provided', async () => { 10 - const result = await readBodyInput('Test body content'); 11 - expect(result).toBe('Test body content'); 12 - }); 8 + describe('direct string input', () => { 9 + it('should return body string when provided', async () => { 10 + const result = await readBodyInput('Test body content'); 11 + expect(result).toBe('Test body content'); 12 + }); 13 13 14 - it('should return multiline body string', async () => { 15 - const multiline = 'Line 1\nLine 2\nLine 3'; 16 - const result = await readBodyInput(multiline); 17 - expect(result).toBe(multiline); 18 - }); 14 + it('should return multiline body string', async () => { 15 + const multiline = 'Line 1\nLine 2\nLine 3'; 16 + const result = await readBodyInput(multiline); 17 + expect(result).toBe(multiline); 18 + }); 19 19 20 - it('should return empty string', async () => { 21 - const result = await readBodyInput(''); 22 - expect(result).toBe(''); 23 - }); 24 - }); 20 + it('should return empty string', async () => { 21 + const result = await readBodyInput(''); 22 + expect(result).toBe(''); 23 + }); 24 + }); 25 25 26 - describe('file input', () => { 27 - let tempDir: string; 28 - let testFile: string; 26 + describe('file input', () => { 27 + let tempDir: string; 28 + let testFile: string; 29 29 30 - beforeEach(async () => { 31 - // Create a temporary directory for test files 32 - tempDir = path.join(process.cwd(), 'tests', 'fixtures', 'temp'); 33 - await fs.mkdir(tempDir, { recursive: true }); 34 - testFile = path.join(tempDir, 'test-body.txt'); 35 - }); 30 + beforeEach(async () => { 31 + // Create a temporary directory for test files 32 + tempDir = path.join(process.cwd(), 'tests', 'fixtures', 'temp'); 33 + await fs.mkdir(tempDir, { recursive: true }); 34 + testFile = path.join(tempDir, 'test-body.txt'); 35 + }); 36 36 37 - afterEach(async () => { 38 - // Clean up test files 39 - try { 40 - await fs.rm(tempDir, { recursive: true, force: true }); 41 - } catch { 42 - // Ignore cleanup errors 43 - } 44 - }); 37 + afterEach(async () => { 38 + // Clean up test files 39 + try { 40 + await fs.rm(tempDir, { recursive: true, force: true }); 41 + } catch { 42 + // Ignore cleanup errors 43 + } 44 + }); 45 45 46 - it('should read content from file', async () => { 47 - const content = 'File content here'; 48 - await fs.writeFile(testFile, content, 'utf-8'); 46 + it('should read content from file', async () => { 47 + const content = 'File content here'; 48 + await fs.writeFile(testFile, content, 'utf-8'); 49 49 50 - const result = await readBodyInput(undefined, testFile); 51 - expect(result).toBe(content); 52 - }); 50 + const result = await readBodyInput(undefined, testFile); 51 + expect(result).toBe(content); 52 + }); 53 53 54 - it('should read multiline content from file', async () => { 55 - const content = 'Line 1\nLine 2\nLine 3'; 56 - await fs.writeFile(testFile, content, 'utf-8'); 54 + it('should read multiline content from file', async () => { 55 + const content = 'Line 1\nLine 2\nLine 3'; 56 + await fs.writeFile(testFile, content, 'utf-8'); 57 57 58 - const result = await readBodyInput(undefined, testFile); 59 - expect(result).toBe(content); 60 - }); 58 + const result = await readBodyInput(undefined, testFile); 59 + expect(result).toBe(content); 60 + }); 61 61 62 - it('should read empty file', async () => { 63 - await fs.writeFile(testFile, '', 'utf-8'); 62 + it('should read empty file', async () => { 63 + await fs.writeFile(testFile, '', 'utf-8'); 64 64 65 - const result = await readBodyInput(undefined, testFile); 66 - expect(result).toBe(''); 67 - }); 65 + const result = await readBodyInput(undefined, testFile); 66 + expect(result).toBe(''); 67 + }); 68 68 69 - it('should throw error when file does not exist', async () => { 70 - const nonExistentFile = path.join(tempDir, 'does-not-exist.txt'); 69 + it('should throw error when file does not exist', async () => { 70 + const nonExistentFile = path.join(tempDir, 'does-not-exist.txt'); 71 71 72 - await expect(readBodyInput(undefined, nonExistentFile)).rejects.toThrow( 73 - `File not found: ${nonExistentFile}`, 74 - ); 75 - }); 72 + await expect(readBodyInput(undefined, nonExistentFile)).rejects.toThrow( 73 + `File not found: ${nonExistentFile}` 74 + ); 75 + }); 76 76 77 - it('should throw error when path is a directory', async () => { 78 - await expect(readBodyInput(undefined, tempDir)).rejects.toThrow( 79 - `'${tempDir}' is a directory, not a file`, 80 - ); 81 - }); 82 - }); 77 + it('should throw error when path is a directory', async () => { 78 + await expect(readBodyInput(undefined, tempDir)).rejects.toThrow( 79 + `'${tempDir}' is a directory, not a file` 80 + ); 81 + }); 82 + }); 83 83 84 - describe('stdin input', () => { 85 - // Note: Stdin reading is tested via integration tests 86 - // Mocking process.stdin is complex and unreliable in unit tests 87 - // The implementation is straightforward and covered by: 88 - // 1. File I/O tests (same event-driven patterns) 89 - // 2. Integration tests with real stdin 90 - it.skip('stdin reading is tested via integration tests', () => { 91 - // Placeholder to document testing approach 92 - }); 93 - }); 84 + describe('stdin input', () => { 85 + // Note: Stdin reading is tested via integration tests 86 + // Mocking process.stdin is complex and unreliable in unit tests 87 + // The implementation is straightforward and covered by: 88 + // 1. File I/O tests (same event-driven patterns) 89 + // 2. Integration tests with real stdin 90 + it.skip('stdin reading is tested via integration tests', () => { 91 + // Placeholder to document testing approach 92 + }); 93 + }); 94 94 95 - describe('no input', () => { 96 - it('should return undefined when no input provided', async () => { 97 - const result = await readBodyInput(); 98 - expect(result).toBeUndefined(); 99 - }); 95 + describe('no input', () => { 96 + it('should return undefined when no input provided', async () => { 97 + const result = await readBodyInput(); 98 + expect(result).toBeUndefined(); 99 + }); 100 100 101 - it('should return undefined when both params are undefined', async () => { 102 - const result = await readBodyInput(undefined, undefined); 103 - expect(result).toBeUndefined(); 104 - }); 105 - }); 101 + it('should return undefined when both params are undefined', async () => { 102 + const result = await readBodyInput(undefined, undefined); 103 + expect(result).toBeUndefined(); 104 + }); 105 + }); 106 106 107 - describe('error cases', () => { 108 - it('should throw error when both bodyString and bodyFilePath provided', async () => { 109 - await expect( 110 - readBodyInput('body text', '/path/to/file'), 111 - ).rejects.toThrow( 112 - 'Cannot specify both --body and --body-file. Choose one input method.', 113 - ); 114 - }); 107 + describe('error cases', () => { 108 + it('should throw error when both bodyString and bodyFilePath provided', async () => { 109 + await expect(readBodyInput('body text', '/path/to/file')).rejects.toThrow( 110 + 'Cannot specify both --body and --body-file. Choose one input method.' 111 + ); 112 + }); 115 113 116 - it('should throw error when both bodyString and stdin flag provided', async () => { 117 - await expect(readBodyInput('body text', '-')).rejects.toThrow( 118 - 'Cannot specify both --body and --body-file. Choose one input method.', 119 - ); 120 - }); 121 - }); 114 + it('should throw error when both bodyString and stdin flag provided', async () => { 115 + await expect(readBodyInput('body text', '-')).rejects.toThrow( 116 + 'Cannot specify both --body and --body-file. Choose one input method.' 117 + ); 118 + }); 119 + }); 122 120 });
+6 -2
tests/utils/validation.test.ts
··· 194 194 195 195 it('should reject titles over 256 characters', () => { 196 196 const tooLong = 'A'.repeat(257); 197 - expect(() => validateIssueTitle(tooLong)).toThrow('Issue title must be 256 characters or less'); 197 + expect(() => validateIssueTitle(tooLong)).toThrow( 198 + 'Issue title must be 256 characters or less' 199 + ); 198 200 }); 199 201 }); 200 202 ··· 215 217 216 218 it('should reject bodies over 50,000 characters', () => { 217 219 const tooLong = 'A'.repeat(50001); 218 - expect(() => validateIssueBody(tooLong)).toThrow('Issue body must be 50,000 characters or less'); 220 + expect(() => validateIssueBody(tooLong)).toThrow( 221 + 'Issue body must be 50,000 characters or less' 222 + ); 219 223 }); 220 224 }); 221 225 });