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

Fix #17: detect locked keychain and preserve credentials on access failure #3

merged opened by markbennett.ca targeting main from fix/keychain-locked-detection
  • Export KeychainAccessError from session.ts; thrown when getPassword() fails (platform error like locked keychain), not when an entry is missing (undefined)
  • resumeSession() now rethrows KeychainAccessError without clearing metadata, so temporarily locked keychains no longer wipe stored credentials
  • Add ensureAuthenticated(client) to auth-helpers.ts: on KeychainAccessError, attempts to unlock the keychain via security unlock-keychain (macOS only), retries once, then falls back to a clear error message with manual instructions
  • Replace 7 repeated inline auth-check blocks in issue.ts with ensureAuthenticated()
  • Add/update tests for KeychainAccessError propagation and ensureAuthenticated behavior

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

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:b2mcbcamkwyznc5fkplwlxbf/sh.tangled.repo.pull/3mekb442mr322
+696 -97
Interdiff #1 โ†’ #2
+11 -1
.claude/settings.json
··· 1 1 { 2 2 "permissions": { 3 - "allow": ["Bash(npm run test:*)", "Bash(npm run build:*)", "Bash(npm test:*)"] 3 + "allow": [ 4 + "Bash(npm run test:*)", 5 + "Bash(npm run build:*)", 6 + "Bash(npm test:*)", 7 + "Bash(npm run typecheck:*)", 8 + "Bash(npm run lint:*)", 9 + "Bash(npm run lint:fix:*)", 10 + "Bash(npm run format:*)", 11 + "Bash(git fetch:*)", 12 + "Bash(git checkout:*)" 13 + ] 4 14 } 5 15 }
+59
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Commands 6 + 7 + ```bash 8 + npm run dev -- <args> # Run CLI in development (use this, not ./tangled) 9 + npm run build # Compile TypeScript to dist/ 10 + npm test # Run all tests once 11 + npm run test:watch # Run tests in watch mode 12 + npm run typecheck # Type-check without building (prefer over npx tsc --noEmit) 13 + npm run lint # Check with Biome 14 + npm run lint:fix # Auto-fix lint/format issues 15 + ``` 16 + 17 + Run a single test file: 18 + ```bash 19 + npx vitest run tests/commands/issue.test.ts 20 + ``` 21 + 22 + ## Architecture 23 + 24 + `src/index.ts` is the entry point โ€” it registers all commands and parses `process.argv`. 25 + 26 + ### Layer structure 27 + 28 + - **`src/commands/`** โ€” Commander.js command factories (e.g. `createIssueCommand()`). Each command: resumes session, gets repo context, calls lib functions, outputs results. 29 + - **`src/lib/`** โ€” Business logic with no Commander dependency: 30 + - `api-client.ts` โ€” `TangledApiClient` wraps `AtpAgent`; `isAuthenticated()` is **synchronous** 31 + - `session.ts` โ€” OS keychain storage via `@napi-rs/keyring`; throws `KeychainAccessError` if keychain is inaccessible (not just missing) 32 + - `context.ts` โ€” Infers repo from `git remote` URLs; resolves `RepositoryContext` with owner DID/handle and repo name 33 + - `issues-api.ts` โ€” All issue CRUD; exports `IssueData` (canonical JSON shape), `getCompleteIssueData`, `resolveSequentialNumber` 34 + - **`src/utils/`** โ€” Stateless helpers: 35 + - `auth-helpers.ts` โ€” `requireAuth(client)` throws if unauthenticated (use in lib functions); `ensureAuthenticated(client)` for commands (calls `resumeSession`, exits on failure) 36 + - `validation.ts` โ€” **All** validation logic lives here (Zod schemas + boolean helpers) 37 + - `formatting.ts` โ€” `outputJson<T extends object>(data, fields?)`, `formatDate`, `formatIssueState` 38 + - `at-uri.ts` โ€” Parse/build AT-URIs and repo AT-URIs 39 + - `body-input.ts` โ€” Reads `--body` / `--body-file` / stdin (`-F -`) 40 + - **`src/lexicon/`** โ€” Auto-generated AT Protocol type definitions; regenerate with `npm run codegen` 41 + 42 + ### Key patterns 43 + 44 + **Issue numbering** โ€” Sequential numbers are not stored; they are computed by sorting all issues for a repo by `createdAt` ascending. The 1-based index is the display number. 45 + 46 + **Issue state** โ€” Stored as separate `sh.tangled.repo.issue.state` records. The latest record wins; default is `'open'` if no record exists. 47 + 48 + **JSON output** โ€” All issue sub-commands use `IssueCommand extends Command` (in `issue.ts`) to share a `--json [fields]` option. The canonical field set is: `number, title, body, state, author, createdAt, uri, cid`. Use `getCompleteIssueData()` to populate all fields. 49 + 50 + **Auth flow** โ€” Commands call `client.resumeSession()` directly, then proceed. Lib functions call `requireAuth(client)`. `KeychainAccessError` from `session.ts` propagates through `resumeSession()` without clearing metadata. 51 + 52 + ### Tests 53 + 54 + Tests mirror `src/` under `tests/`. Command tests mock the entire `issuesApi` module: 55 + ```typescript 56 + vi.mock('../../src/lib/issues-api.js'); 57 + // Use importOriginal to preserve exported classes/errors if needed 58 + ``` 59 + `isAuthenticated()` is synchronous โ€” mock as `vi.fn(() => true)`, not `vi.fn(async () => true)`.
+107 -63
src/commands/issue.ts
··· 7 7 closeIssue, 8 8 createIssue, 9 9 deleteIssue, 10 - getIssue, 10 + getCompleteIssueData, 11 11 getIssueState, 12 12 listIssues, 13 13 reopenIssue, 14 + resolveSequentialNumber, 14 15 updateIssue, 15 16 } from '../lib/issues-api.js'; 17 + import type { IssueData } from '../lib/issues-api.js'; 16 18 import { buildRepoAtUri } from '../utils/at-uri.js'; 17 19 import { ensureAuthenticated, requireAuth } from '../utils/auth-helpers.js'; 18 20 import { readBodyInput } from '../utils/body-input.js'; ··· 87 89 } 88 90 89 91 /** 92 + * A custom subclass of Command with support for adding the common issue JSON flag. 93 + */ 94 + class IssueCommand extends Command { 95 + addIssueJsonOption() { 96 + return this.option( 97 + '--json [fields]', 98 + 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' 99 + ); 100 + } 101 + } 102 + 103 + /** 90 104 * Issue view subcommand 91 105 */ 92 106 function createViewCommand(): Command { 93 - return new Command('view') 107 + return new IssueCommand('view') 94 108 .description('View details of a specific issue') 95 109 .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey') 96 - .option( 97 - '--json [fields]', 98 - 'Output JSON; optionally specify comma-separated fields (title, body, state, author, createdAt, uri, cid)' 99 - ) 110 + .addIssueJsonOption() 100 111 .action(async (issueId: string, options: { json?: string | true }) => { 101 112 try { 102 113 // 1. Validate auth ··· 118 129 // 4. Resolve issue ID to URI 119 130 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 120 131 121 - // 5. Fetch issue details 122 - const issue = await getIssue({ client, issueUri }); 132 + // 5. Fetch complete issue data (record, sequential number, state) 133 + const issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); 123 134 124 - // 6. Fetch issue state 125 - const state = await getIssueState({ client, issueUri: issue.uri }); 126 - 127 - // 7. Output result 135 + // 6. Output result 128 136 if (options.json !== undefined) { 129 - const issueData = { 130 - title: issue.title, 131 - body: issue.body, 132 - state, 133 - author: issue.author, 134 - createdAt: issue.createdAt, 135 - uri: issue.uri, 136 - cid: issue.cid, 137 - }; 138 137 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 139 138 return; 140 139 } 141 140 142 - console.log(`\nIssue ${displayId} ${formatIssueState(state)}`); 143 - console.log(`Title: ${issue.title}`); 144 - console.log(`Author: ${issue.author}`); 145 - console.log(`Created: ${formatDate(issue.createdAt)}`); 141 + console.log(`\nIssue ${displayId} ${formatIssueState(issueData.state)}`); 142 + console.log(`Title: ${issueData.title}`); 143 + console.log(`Author: ${issueData.author}`); 144 + console.log(`Created: ${formatDate(issueData.createdAt)}`); 146 145 console.log(`Repo: ${context.name}`); 147 - console.log(`URI: ${issue.uri}`); 146 + console.log(`URI: ${issueData.uri}`); 148 147 149 - if (issue.body) { 148 + if (issueData.body) { 150 149 console.log('\nBody:'); 151 - console.log(issue.body); 150 + console.log(issueData.body); 152 151 } 153 152 154 153 console.log(); // Empty line at end ··· 165 164 * Issue edit subcommand 166 165 */ 167 166 function createEditCommand(): Command { 168 - return new Command('edit') 167 + return new IssueCommand('edit') 169 168 .description('Edit an issue title and/or body') 170 169 .argument('<issue-id>', 'Issue number or rkey') 171 170 .option('-t, --title <string>', 'New issue title') 172 171 .option('-b, --body <string>', 'New issue body text') 173 172 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 174 - .option( 175 - '--json [fields]', 176 - 'Output JSON of the updated issue; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 177 - ) 173 + .addIssueJsonOption() 178 174 .action( 179 175 async ( 180 176 issueId: string, ··· 223 219 224 220 // 9. Output result 225 221 if (options.json !== undefined) { 226 - const issueData = { 222 + const [number, state] = await Promise.all([ 223 + resolveSequentialNumber(displayId, updatedIssue.uri, client, repoAtUri), 224 + getIssueState({ client, issueUri: updatedIssue.uri }), 225 + ]); 226 + const issueData: IssueData = { 227 + number, 227 228 title: updatedIssue.title, 228 229 body: updatedIssue.body, 230 + state, 229 231 author: updatedIssue.author, 230 232 createdAt: updatedIssue.createdAt, 231 233 uri: updatedIssue.uri, ··· 255 257 * Issue close subcommand 256 258 */ 257 259 function createCloseCommand(): Command { 258 - return new Command('close') 260 + return new IssueCommand('close') 259 261 .description('Close an issue') 260 262 .argument('<issue-id>', 'Issue number or rkey') 261 - .action(async (issueId: string) => { 263 + .addIssueJsonOption() 264 + .action(async (issueId: string, options: { json?: string | true }) => { 262 265 try { 263 266 // 1. Validate auth 264 267 const client = createApiClient(); ··· 279 282 // 4. Resolve issue ID to URI 280 283 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 281 284 282 - // 5. Close issue 285 + // 5. Fetch complete issue data (state will be 'closed' after operation) 286 + const issueData = await getCompleteIssueData( 287 + client, 288 + issueUri, 289 + displayId, 290 + repoAtUri, 291 + 'closed' 292 + ); 293 + 294 + // 6. Close issue 283 295 await closeIssue({ client, issueUri }); 284 296 285 - // 6. Display success 286 - console.log(`โœ“ Issue ${displayId} closed`); 297 + // 7. Display success 298 + if (options.json !== undefined) { 299 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 300 + } else { 301 + console.log(`โœ“ Issue ${displayId} closed`); 302 + console.log(` Title: ${issueData.title}`); 303 + } 287 304 } catch (error) { 288 305 console.error( 289 306 `โœ— Failed to close issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 297 314 * Issue reopen subcommand 298 315 */ 299 316 function createReopenCommand(): Command { 300 - return new Command('reopen') 317 + return new IssueCommand('reopen') 301 318 .description('Reopen a closed issue') 302 319 .argument('<issue-id>', 'Issue number or rkey') 303 - .action(async (issueId: string) => { 320 + .addIssueJsonOption() 321 + .action(async (issueId: string, options: { json?: string | true }) => { 304 322 try { 305 323 // 1. Validate auth 306 324 const client = createApiClient(); ··· 321 339 // 4. Resolve issue ID to URI 322 340 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 323 341 324 - // 5. Reopen issue 342 + // 5. Fetch complete issue data (state will be 'open' after operation) 343 + const issueData = await getCompleteIssueData( 344 + client, 345 + issueUri, 346 + displayId, 347 + repoAtUri, 348 + 'open' 349 + ); 350 + 351 + // 6. Reopen issue 325 352 await reopenIssue({ client, issueUri }); 326 353 327 - // 6. Display success 328 - console.log(`โœ“ Issue ${displayId} reopened`); 354 + // 7. Display success 355 + if (options.json !== undefined) { 356 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 357 + } else { 358 + console.log(`โœ“ Issue ${displayId} reopened`); 359 + console.log(` Title: ${issueData.title}`); 360 + } 329 361 } catch (error) { 330 362 console.error( 331 363 `โœ— Failed to reopen issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 339 371 * Issue delete subcommand 340 372 */ 341 373 function createDeleteCommand(): Command { 342 - return new Command('delete') 374 + return new IssueCommand('delete') 343 375 .description('Delete an issue permanently') 344 376 .argument('<issue-id>', 'Issue number or rkey') 345 377 .option('-f, --force', 'Skip confirmation prompt') 346 - .action(async (issueId: string, options: { force?: boolean }) => { 378 + .addIssueJsonOption() 379 + .action(async (issueId: string, options: { force?: boolean; json?: string | true }) => { 347 380 // 1. Validate auth 348 381 const client = createApiClient(); 349 382 await ensureAuthenticated(client); ··· 357 390 process.exit(1); 358 391 } 359 392 360 - // 3. Build repo AT-URI and resolve issue ID 393 + // 3. Build repo AT-URI, resolve issue ID, and fetch issue details 361 394 let issueUri: string; 362 395 let displayId: string; 396 + let issueData: IssueData; 363 397 try { 364 398 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 365 399 ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri)); 400 + issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); 366 401 } catch (error) { 367 402 console.error( 368 403 `โœ— Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 373 408 // 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly) 374 409 if (!options.force) { 375 410 const confirmed = await confirm({ 376 - message: `Are you sure you want to delete issue ${displayId}? This cannot be undone.`, 411 + message: `Are you sure you want to delete issue ${displayId} "${issueData.title}"? This cannot be undone.`, 377 412 default: false, 378 413 }); 379 414 ··· 386 421 // 5. Delete issue 387 422 try { 388 423 await deleteIssue({ client, issueUri }); 389 - console.log(`โœ“ Issue ${displayId} deleted`); 424 + if (options.json !== undefined) { 425 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 426 + } else { 427 + console.log(`โœ“ Issue ${displayId} deleted`); 428 + console.log(` Title: ${issueData.title}`); 429 + } 390 430 } catch (error) { 391 431 console.error( 392 432 `โœ— Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 418 458 * Issue create subcommand 419 459 */ 420 460 function createCreateCommand(): Command { 421 - return new Command('create') 461 + return new IssueCommand('create') 422 462 .description('Create a new issue') 423 463 .argument('<title>', 'Issue title') 424 464 .option('-b, --body <string>', 'Issue body text') 425 465 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 426 - .option( 427 - '--json [fields]', 428 - 'Output JSON; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 429 - ) 466 + .addIssueJsonOption() 430 467 .action( 431 468 async ( 432 469 title: string, ··· 469 506 body, 470 507 }); 471 508 472 - // 7. Output result 509 + // 7. Compute sequential number 510 + const { issues: allIssues } = await listIssues({ client, repoAtUri, limit: 100 }); 511 + const sortedAll = allIssues.sort( 512 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 513 + ); 514 + const idx = sortedAll.findIndex((i) => i.uri === issue.uri); 515 + const number = idx >= 0 ? idx + 1 : undefined; 516 + 517 + // 8. Output result 473 518 if (options.json !== undefined) { 474 - const issueData = { 519 + const issueData: IssueData = { 520 + number, 475 521 title: issue.title, 476 522 body: issue.body, 523 + state: 'open', 477 524 author: issue.author, 478 525 createdAt: issue.createdAt, 479 526 uri: issue.uri, ··· 483 530 return; 484 531 } 485 532 486 - const rkey = extractRkey(issue.uri); 487 - console.log(`\nโœ“ Issue created: #${rkey}`); 533 + const displayNumber = number !== undefined ? `#${number}` : extractRkey(issue.uri); 534 + console.log(`\nโœ“ Issue ${displayNumber} created`); 488 535 console.log(` Title: ${issue.title}`); 489 536 console.log(` URI: ${issue.uri}`); 490 537 } catch (error) { ··· 501 548 * Issue list subcommand 502 549 */ 503 550 function createListCommand(): Command { 504 - return new Command('list') 551 + return new IssueCommand('list') 505 552 .description('List issues for the current repository') 506 553 .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50') 507 - .option( 508 - '--json [fields]', 509 - 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' 510 - ) 554 + .addIssueJsonOption() 511 555 .action(async (options: { limit: string; json?: string | true }) => { 512 556 try { 513 557 // 1. Validate auth
src/lib/api-client.ts

This file has not been changed.

+66
src/lib/issues-api.ts
··· 424 424 } 425 425 426 426 /** 427 + * Resolve a sequential issue number from a displayId or by scanning the issue list. 428 + * Fast path: if displayId is "#N", return N directly. 429 + * Fallback: fetch all issues, sort oldest-first, return 1-based position. 430 + */ 431 + export async function resolveSequentialNumber( 432 + displayId: string, 433 + issueUri: string, 434 + client: TangledApiClient, 435 + repoAtUri: string 436 + ): Promise<number | undefined> { 437 + const match = displayId.match(/^#(\d+)$/); 438 + if (match) return Number.parseInt(match[1], 10); 439 + 440 + const { issues } = await listIssues({ client, repoAtUri, limit: 100 }); 441 + const sorted = issues.sort( 442 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 443 + ); 444 + const idx = sorted.findIndex((i) => i.uri === issueUri); 445 + return idx >= 0 ? idx + 1 : undefined; 446 + } 447 + 448 + /** 449 + * Canonical JSON shape for a single issue, used by all issue commands. 450 + */ 451 + export interface IssueData { 452 + number: number | undefined; 453 + title: string; 454 + body?: string; 455 + state: 'open' | 'closed'; 456 + author: string; 457 + createdAt: string; 458 + uri: string; 459 + cid: string; 460 + } 461 + 462 + /** 463 + * Fetch a complete IssueData object ready for JSON output. 464 + * Fetches the issue record and sequential number in parallel. 465 + * If stateOverride is supplied (e.g. 'closed' after a close operation), 466 + * getIssueState is skipped; otherwise the current state is fetched. 467 + */ 468 + export async function getCompleteIssueData( 469 + client: TangledApiClient, 470 + issueUri: string, 471 + displayId: string, 472 + repoAtUri: string, 473 + stateOverride?: 'open' | 'closed' 474 + ): Promise<IssueData> { 475 + const [issue, number] = await Promise.all([ 476 + getIssue({ client, issueUri }), 477 + resolveSequentialNumber(displayId, issueUri, client, repoAtUri), 478 + ]); 479 + const state = stateOverride ?? (await getIssueState({ client, issueUri })); 480 + return { 481 + number, 482 + title: issue.title, 483 + body: issue.body, 484 + state, 485 + author: issue.author, 486 + createdAt: issue.createdAt, 487 + uri: issue.uri, 488 + cid: issue.cid, 489 + }; 490 + } 491 + 492 + /** 427 493 * Reopen a closed issue by creating an open state record 428 494 */ 429 495 export async function reopenIssue(params: ReopenIssueParams): Promise<void> {
src/lib/session.ts

This file has not been changed.

src/utils/auth-helpers.ts

This file has not been changed.

+3 -6
src/utils/formatting.ts
··· 45 45 * @param data - The data to output (object or array of objects) 46 46 * @param fields - Comma-separated field names to include; omit for all fields 47 47 */ 48 - export function outputJson( 49 - data: Record<string, unknown> | Record<string, unknown>[], 50 - fields?: string 51 - ): void { 48 + export function outputJson<T extends object>(data: T | T[], fields?: string): void { 52 49 if (fields) { 53 50 const fieldList = fields 54 51 .split(',') ··· 57 54 if (Array.isArray(data)) { 58 55 console.log( 59 56 JSON.stringify( 60 - data.map((item) => pickFields(item, fieldList)), 57 + data.map((item) => pickFields(item as Record<string, unknown>, fieldList)), 61 58 null, 62 59 2 63 60 ) 64 61 ); 65 62 } else { 66 - console.log(JSON.stringify(pickFields(data, fieldList), null, 2)); 63 + console.log(JSON.stringify(pickFields(data as Record<string, unknown>, fieldList), null, 2)); 67 64 } 68 65 } else { 69 66 console.log(JSON.stringify(data, null, 2));
+226 -27
tests/commands/issue.test.ts
··· 55 55 56 56 // Mock body input 57 57 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 58 + 59 + // Default listIssues mock (needed for sequential number computation after create) 60 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined }); 58 61 }); 59 62 60 63 afterEach(() => { ··· 76 79 77 80 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Test body'); 78 81 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 82 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 79 83 80 84 const command = createIssueCommand(); 81 85 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', 'Test body']); ··· 88 92 }); 89 93 90 94 expect(consoleLogSpy).toHaveBeenCalledWith('Creating issue...'); 91 - expect(consoleLogSpy).toHaveBeenCalledWith('\nโœ“ Issue created: #abc123'); 95 + expect(consoleLogSpy).toHaveBeenCalledWith('\nโœ“ Issue #1 created'); 92 96 }); 93 97 }); 94 98 ··· 107 111 108 112 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Body from file'); 109 113 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 114 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 110 115 111 116 const command = createIssueCommand(); 112 117 await command.parseAsync([ ··· 142 147 143 148 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 144 149 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 150 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 145 151 146 152 const command = createIssueCommand(); 147 153 await command.parseAsync(['node', 'test', 'create', 'Test Issue']); ··· 266 272 267 273 it('should output JSON of created issue when --json is passed', async () => { 268 274 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 275 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 269 276 270 277 const command = createIssueCommand(); 271 278 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json']); ··· 275 282 276 283 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 277 284 expect(jsonOutput).toMatchObject({ 285 + number: 1, 278 286 title: 'Test Issue', 279 287 body: 'Test body', 280 288 author: 'did:plc:abc123', ··· 285 293 286 294 it('should output filtered JSON when --json with fields is passed', async () => { 287 295 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 296 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 288 297 289 298 const command = createIssueCommand(); 290 - await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'title,uri']); 299 + await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'number,uri']); 291 300 292 301 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 293 302 expect(jsonOutput).toEqual({ 294 - title: 'Test Issue', 303 + number: 1, 295 304 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 296 305 }); 306 + expect(jsonOutput).not.toHaveProperty('title'); 297 307 expect(jsonOutput).not.toHaveProperty('body'); 298 308 expect(jsonOutput).not.toHaveProperty('author'); 299 309 }); ··· 611 621 issues: [mockIssue], 612 622 cursor: undefined, 613 623 }); 614 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 615 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 624 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 625 + number: 1, 626 + title: mockIssue.title, 627 + body: mockIssue.body, 628 + state: 'open', 629 + author: mockIssue.author, 630 + createdAt: mockIssue.createdAt, 631 + uri: mockIssue.uri, 632 + cid: mockIssue.cid, 633 + }); 616 634 617 635 const command = createIssueCommand(); 618 636 await command.parseAsync(['node', 'test', 'view', '1']); 619 637 620 - expect(issuesApi.getIssue).toHaveBeenCalledWith({ 621 - client: mockClient, 622 - issueUri: mockIssue.uri, 623 - }); 624 - expect(issuesApi.getIssueState).toHaveBeenCalledWith({ 625 - client: mockClient, 626 - issueUri: mockIssue.uri, 627 - }); 638 + expect(issuesApi.getCompleteIssueData).toHaveBeenCalledWith( 639 + mockClient, 640 + mockIssue.uri, 641 + '#1', 642 + 'at://did:plc:abc123/sh.tangled.repo/xyz789' 643 + ); 628 644 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue #1 [OPEN]'); 629 645 expect(consoleLogSpy).toHaveBeenCalledWith('Title: Test Issue'); 630 646 expect(consoleLogSpy).toHaveBeenCalledWith('\nBody:'); ··· 632 648 }); 633 649 634 650 it('should view issue by rkey', async () => { 635 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 636 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); 651 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 652 + number: undefined, 653 + title: mockIssue.title, 654 + body: mockIssue.body, 655 + state: 'closed', 656 + author: mockIssue.author, 657 + createdAt: mockIssue.createdAt, 658 + uri: mockIssue.uri, 659 + cid: mockIssue.cid, 660 + }); 637 661 638 662 const command = createIssueCommand(); 639 663 await command.parseAsync(['node', 'test', 'view', 'issue1']); 640 664 641 - expect(issuesApi.getIssue).toHaveBeenCalledWith({ 642 - client: mockClient, 643 - issueUri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 644 - }); 665 + expect(issuesApi.getCompleteIssueData).toHaveBeenCalledWith( 666 + mockClient, 667 + 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 668 + 'issue1', 669 + 'at://did:plc:abc123/sh.tangled.repo/xyz789' 670 + ); 645 671 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue issue1 [CLOSED]'); 646 672 }); 647 673 648 674 it('should show issue without body', async () => { 649 - const issueWithoutBody = { ...mockIssue, body: undefined }; 650 675 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 651 - issues: [issueWithoutBody], 676 + issues: [mockIssue], 652 677 cursor: undefined, 653 678 }); 654 - vi.mocked(issuesApi.getIssue).mockResolvedValue(issueWithoutBody); 655 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 679 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 680 + number: 1, 681 + title: mockIssue.title, 682 + body: undefined, 683 + state: 'open', 684 + author: mockIssue.author, 685 + createdAt: mockIssue.createdAt, 686 + uri: mockIssue.uri, 687 + cid: mockIssue.cid, 688 + }); 656 689 657 690 const command = createIssueCommand(); 658 691 await command.parseAsync(['node', 'test', 'view', '1']); ··· 708 741 issues: [mockIssue], 709 742 cursor: undefined, 710 743 }); 711 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 712 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 744 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 745 + number: 1, 746 + title: mockIssue.title, 747 + body: mockIssue.body, 748 + state: 'open', 749 + author: mockIssue.author, 750 + createdAt: mockIssue.createdAt, 751 + uri: mockIssue.uri, 752 + cid: mockIssue.cid, 753 + }); 713 754 714 755 const command = createIssueCommand(); 715 756 await command.parseAsync(['node', 'test', 'view', '1', '--json']); 716 757 717 758 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 718 759 expect(jsonOutput).toMatchObject({ 760 + number: 1, 719 761 title: 'Test Issue', 720 762 body: 'Issue body', 721 763 state: 'open', ··· 730 772 issues: [mockIssue], 731 773 cursor: undefined, 732 774 }); 733 - vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 734 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); 775 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 776 + number: 1, 777 + title: mockIssue.title, 778 + body: mockIssue.body, 779 + state: 'closed', 780 + author: mockIssue.author, 781 + createdAt: mockIssue.createdAt, 782 + uri: mockIssue.uri, 783 + cid: mockIssue.cid, 784 + }); 735 785 736 786 const command = createIssueCommand(); 737 787 await command.parseAsync(['node', 'test', 'view', '1', '--json', 'title,state']); ··· 868 918 cursor: undefined, 869 919 }); 870 920 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 921 + vi.mocked(issuesApi.resolveSequentialNumber).mockResolvedValue(1); 922 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 871 923 872 924 const command = createIssueCommand(); 873 925 await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title', '--json']); 874 926 875 927 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 876 928 expect(jsonOutput).toMatchObject({ 929 + number: 1, 877 930 title: 'New Title', 931 + state: 'open', 878 932 author: 'did:plc:abc123', 879 933 uri: mockIssue.uri, 880 934 cid: mockIssue.cid, ··· 890 944 cursor: undefined, 891 945 }); 892 946 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 947 + vi.mocked(issuesApi.resolveSequentialNumber).mockResolvedValue(1); 948 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 893 949 894 950 const command = createIssueCommand(); 895 951 await command.parseAsync([ ··· 949 1005 }); 950 1006 951 1007 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1008 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1009 + number: 1, 1010 + title: mockIssue.title, 1011 + body: undefined, 1012 + state: 'closed', 1013 + author: mockIssue.author, 1014 + createdAt: mockIssue.createdAt, 1015 + uri: mockIssue.uri, 1016 + cid: mockIssue.cid, 1017 + }); 952 1018 }); 953 1019 954 1020 afterEach(() => { ··· 970 1036 issueUri: mockIssue.uri, 971 1037 }); 972 1038 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 closed'); 1039 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 973 1040 }); 974 1041 975 1042 it('should fail when not authenticated', async () => { ··· 983 1050 'process.exit(1)' 984 1051 ); 985 1052 }); 1053 + 1054 + describe('JSON output', () => { 1055 + it('should output JSON when --json is passed', async () => { 1056 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1057 + vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 1058 + 1059 + const command = createIssueCommand(); 1060 + await command.parseAsync(['node', 'test', 'close', '1', '--json']); 1061 + 1062 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1063 + expect(jsonOutput).toEqual({ 1064 + number: 1, 1065 + title: 'Test Issue', 1066 + state: 'closed', 1067 + author: mockIssue.author, 1068 + createdAt: mockIssue.createdAt, 1069 + uri: mockIssue.uri, 1070 + cid: mockIssue.cid, 1071 + }); 1072 + }); 1073 + 1074 + it('should output filtered JSON when --json with fields is passed', async () => { 1075 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1076 + vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 1077 + 1078 + const command = createIssueCommand(); 1079 + await command.parseAsync(['node', 'test', 'close', '1', '--json', 'number,state']); 1080 + 1081 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1082 + expect(jsonOutput).toEqual({ number: 1, state: 'closed' }); 1083 + expect(jsonOutput).not.toHaveProperty('title'); 1084 + expect(jsonOutput).not.toHaveProperty('uri'); 1085 + }); 1086 + }); 986 1087 }); 987 1088 988 1089 ··· 1021 1122 }); 1022 1123 1023 1124 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1125 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1126 + number: 1, 1127 + title: mockIssue.title, 1128 + body: undefined, 1129 + state: 'open', 1130 + author: mockIssue.author, 1131 + createdAt: mockIssue.createdAt, 1132 + uri: mockIssue.uri, 1133 + cid: mockIssue.cid, 1134 + }); 1024 1135 }); 1025 1136 1026 1137 afterEach(() => { ··· 1042 1153 issueUri: mockIssue.uri, 1043 1154 }); 1044 1155 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 reopened'); 1156 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1045 1157 }); 1046 1158 1047 1159 it('should fail when not authenticated', async () => { ··· 1055 1167 'process.exit(1)' 1056 1168 ); 1057 1169 }); 1170 + 1171 + describe('JSON output', () => { 1172 + it('should output JSON when --json is passed', async () => { 1173 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1174 + vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1175 + 1176 + const command = createIssueCommand(); 1177 + await command.parseAsync(['node', 'test', 'reopen', '1', '--json']); 1178 + 1179 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1180 + expect(jsonOutput).toEqual({ 1181 + number: 1, 1182 + title: 'Test Issue', 1183 + state: 'open', 1184 + author: mockIssue.author, 1185 + createdAt: mockIssue.createdAt, 1186 + uri: mockIssue.uri, 1187 + cid: mockIssue.cid, 1188 + }); 1189 + }); 1190 + 1191 + it('should output filtered JSON when --json with fields is passed', async () => { 1192 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1193 + vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1194 + 1195 + const command = createIssueCommand(); 1196 + await command.parseAsync(['node', 'test', 'reopen', '1', '--json', 'number,state']); 1197 + 1198 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1199 + expect(jsonOutput).toEqual({ number: 1, state: 'open' }); 1200 + expect(jsonOutput).not.toHaveProperty('title'); 1201 + }); 1202 + }); 1058 1203 }); 1059 1204 1060 1205 describe('issue delete command', () => { ··· 1095 1240 }); 1096 1241 1097 1242 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1243 + vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1244 + number: 1, 1245 + title: mockIssue.title, 1246 + body: undefined, 1247 + state: 'open', 1248 + author: mockIssue.author, 1249 + createdAt: mockIssue.createdAt, 1250 + uri: mockIssue.uri, 1251 + cid: mockIssue.cid, 1252 + }); 1098 1253 }); 1099 1254 1100 1255 afterEach(() => { ··· 1116 1271 issueUri: mockIssue.uri, 1117 1272 }); 1118 1273 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 deleted'); 1274 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1119 1275 }); 1120 1276 1121 1277 it('should cancel deletion when user declines confirmation', async () => { ··· 1152 1308 1153 1309 expect(issuesApi.deleteIssue).toHaveBeenCalled(); 1154 1310 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 deleted'); 1311 + expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1155 1312 }); 1156 1313 1157 1314 it('should fail when not authenticated', async () => { ··· 1168 1325 expect(consoleErrorSpy).toHaveBeenCalledWith( 1169 1326 'โœ— Not authenticated. Run "tangled auth login" first.' 1170 1327 ); 1328 + }); 1329 + 1330 + describe('JSON output', () => { 1331 + it('should output JSON when --json is passed', async () => { 1332 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1333 + vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1334 + 1335 + const command = createIssueCommand(); 1336 + await command.parseAsync(['node', 'test', 'delete', '1', '--force', '--json']); 1337 + 1338 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1339 + expect(jsonOutput).toEqual({ 1340 + number: 1, 1341 + title: 'Test Issue', 1342 + state: 'open', 1343 + author: mockIssue.author, 1344 + createdAt: mockIssue.createdAt, 1345 + uri: mockIssue.uri, 1346 + cid: mockIssue.cid, 1347 + }); 1348 + }); 1349 + 1350 + it('should output filtered JSON when --json with fields is passed', async () => { 1351 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1352 + vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1353 + 1354 + const command = createIssueCommand(); 1355 + await command.parseAsync([ 1356 + 'node', 1357 + 'test', 1358 + 'delete', 1359 + '1', 1360 + '--force', 1361 + '--json', 1362 + 'number,title', 1363 + ]); 1364 + 1365 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1366 + expect(jsonOutput).toEqual({ number: 1, title: 'Test Issue' }); 1367 + expect(jsonOutput).not.toHaveProperty('uri'); 1368 + expect(jsonOutput).not.toHaveProperty('cid'); 1369 + }); 1171 1370 }); 1172 1371 });
tests/lib/api-client.test.ts

This file has not been changed.

+224
tests/lib/issues-api.test.ts
··· 4 4 closeIssue, 5 5 createIssue, 6 6 deleteIssue, 7 + getCompleteIssueData, 7 8 getIssue, 8 9 getIssueState, 9 10 listIssues, 10 11 reopenIssue, 12 + resolveSequentialNumber, 11 13 updateIssue, 12 14 } from '../../src/lib/issues-api.js'; 13 15 ··· 861 863 862 864 863 865 ).rejects.toThrow('Must be authenticated'); 866 + }); 867 + }); 868 + 869 + describe('resolveSequentialNumber', () => { 870 + let mockClient: TangledApiClient; 871 + 872 + beforeEach(() => { 873 + mockClient = createMockClient(true); 874 + }); 875 + 876 + it('should return number directly for #N displayId without an API call (fast path)', async () => { 877 + const result = await resolveSequentialNumber( 878 + '#3', 879 + 'at://did:plc:owner/sh.tangled.repo.issue/issue3', 880 + mockClient, 881 + 'at://did:plc:owner/sh.tangled.repo/my-repo' 882 + ); 883 + expect(result).toBe(3); 884 + }); 885 + 886 + it('should scan issue list and return 1-based position for rkey displayId', async () => { 887 + const mockListRecords = vi.fn().mockResolvedValue({ 888 + data: { 889 + records: [ 890 + { 891 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 892 + cid: 'cid1', 893 + value: { 894 + $type: 'sh.tangled.repo.issue', 895 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 896 + title: 'First', 897 + createdAt: '2024-01-01T00:00:00.000Z', 898 + }, 899 + }, 900 + { 901 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 902 + cid: 'cid2', 903 + value: { 904 + $type: 'sh.tangled.repo.issue', 905 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 906 + title: 'Second', 907 + createdAt: '2024-01-02T00:00:00.000Z', 908 + }, 909 + }, 910 + ], 911 + cursor: undefined, 912 + }, 913 + }); 914 + 915 + vi.mocked(mockClient.getAgent).mockReturnValue({ 916 + com: { atproto: { repo: { listRecords: mockListRecords } } }, 917 + } as never); 918 + 919 + const result = await resolveSequentialNumber( 920 + 'issue-b', 921 + 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 922 + mockClient, 923 + 'at://did:plc:owner/sh.tangled.repo/my-repo' 924 + ); 925 + expect(result).toBe(2); 926 + }); 927 + 928 + it('should return undefined when issue URI not found in list', async () => { 929 + const mockListRecords = vi.fn().mockResolvedValue({ 930 + data: { 931 + records: [ 932 + { 933 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 934 + cid: 'cid1', 935 + value: { 936 + $type: 'sh.tangled.repo.issue', 937 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 938 + title: 'First', 939 + createdAt: '2024-01-01T00:00:00.000Z', 940 + }, 941 + }, 942 + ], 943 + cursor: undefined, 944 + }, 945 + }); 946 + 947 + vi.mocked(mockClient.getAgent).mockReturnValue({ 948 + com: { atproto: { repo: { listRecords: mockListRecords } } }, 949 + } as never); 950 + 951 + const result = await resolveSequentialNumber( 952 + 'nonexistent', 953 + 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', 954 + mockClient, 955 + 'at://did:plc:owner/sh.tangled.repo/my-repo' 956 + ); 957 + expect(result).toBeUndefined(); 958 + }); 959 + }); 960 + 961 + describe('getCompleteIssueData', () => { 962 + let mockClient: TangledApiClient; 963 + 964 + beforeEach(() => { 965 + mockClient = createMockClient(true); 966 + }); 967 + 968 + it('should return all fields including fetched state', async () => { 969 + const mockGetRecord = vi.fn().mockResolvedValue({ 970 + data: { 971 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 972 + cid: 'cid1', 973 + value: { 974 + $type: 'sh.tangled.repo.issue', 975 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 976 + title: 'Test Issue', 977 + body: 'Test body', 978 + createdAt: '2024-01-01T00:00:00.000Z', 979 + }, 980 + }, 981 + }); 982 + 983 + // getIssueState uses listRecords on the state collection 984 + const mockListRecords = vi.fn().mockResolvedValue({ 985 + data: { 986 + records: [ 987 + { 988 + uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1', 989 + cid: 'scid1', 990 + value: { 991 + issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 992 + state: 'sh.tangled.repo.issue.state.closed', 993 + }, 994 + }, 995 + ], 996 + }, 997 + }); 998 + 999 + vi.mocked(mockClient.getAgent).mockReturnValue({ 1000 + com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 1001 + } as never); 1002 + 1003 + const result = await getCompleteIssueData( 1004 + mockClient, 1005 + 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1006 + '#1', // fast-path for number โ€” no listRecords call for issues 1007 + 'at://did:plc:owner/sh.tangled.repo/my-repo' 1008 + ); 1009 + 1010 + expect(result).toEqual({ 1011 + number: 1, 1012 + title: 'Test Issue', 1013 + body: 'Test body', 1014 + state: 'closed', 1015 + author: 'did:plc:owner', 1016 + createdAt: '2024-01-01T00:00:00.000Z', 1017 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1018 + cid: 'cid1', 1019 + }); 1020 + }); 1021 + 1022 + it('should use stateOverride and skip the getIssueState network call', async () => { 1023 + const mockGetRecord = vi.fn().mockResolvedValue({ 1024 + data: { 1025 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1026 + cid: 'cid1', 1027 + value: { 1028 + $type: 'sh.tangled.repo.issue', 1029 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 1030 + title: 'Test Issue', 1031 + createdAt: '2024-01-01T00:00:00.000Z', 1032 + }, 1033 + }, 1034 + }); 1035 + 1036 + const mockListRecords = vi.fn(); 1037 + vi.mocked(mockClient.getAgent).mockReturnValue({ 1038 + com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 1039 + } as never); 1040 + 1041 + const result = await getCompleteIssueData( 1042 + mockClient, 1043 + 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1044 + '#2', 1045 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 1046 + 'closed' 1047 + ); 1048 + 1049 + expect(result.number).toBe(2); 1050 + expect(result.state).toBe('closed'); 1051 + expect(mockListRecords).not.toHaveBeenCalled(); 1052 + }); 1053 + 1054 + it('should return undefined body and default open state when issue has no body or state records', async () => { 1055 + const mockGetRecord = vi.fn().mockResolvedValue({ 1056 + data: { 1057 + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1058 + cid: 'cid1', 1059 + value: { 1060 + $type: 'sh.tangled.repo.issue', 1061 + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 1062 + title: 'No body issue', 1063 + createdAt: '2024-01-01T00:00:00.000Z', 1064 + }, 1065 + }, 1066 + }); 1067 + 1068 + vi.mocked(mockClient.getAgent).mockReturnValue({ 1069 + com: { 1070 + atproto: { 1071 + repo: { 1072 + getRecord: mockGetRecord, 1073 + listRecords: vi.fn().mockResolvedValue({ data: { records: [] } }), 1074 + }, 1075 + }, 1076 + }, 1077 + } as never); 1078 + 1079 + const result = await getCompleteIssueData( 1080 + mockClient, 1081 + 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1082 + '#1', 1083 + 'at://did:plc:owner/sh.tangled.repo/my-repo' 1084 + ); 1085 + 1086 + expect(result.body).toBeUndefined(); 1087 + expect(result.state).toBe('open'); 864 1088 }); 865 1089 });
tests/lib/session.test.ts

This file has not been changed.

tests/utils/auth-helpers.test.ts

This file has not been changed.

History

4 rounds 1 comment
sign up or login to add to the discussion
5 commits
expand
Fix #17: detect locked keychain and preserve credentials on access failure
Move session metadata from keychain to plain file to prevent credential loss
chore: allow git fetch in Claude permissions
Formatting
Update pipeline cloning
expand 1 comment

Alright, I haven't seen the error for a while now. Will try this and create a new issue if it comes up again!

pull request successfully merged
4 commits
expand
Fix #17: detect locked keychain and preserve credentials on access failure
Move session metadata from keychain to plain file to prevent credential loss
chore: allow git fetch in Claude permissions
Formatting
expand 0 comments
2 commits
expand
Fix #17: detect locked keychain and preserve credentials on access failure
Move session metadata from keychain to plain file to prevent credential loss
expand 0 comments
1 commit
expand
Fix #17: detect locked keychain and preserve credentials on access failure
expand 0 comments