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

Remove delete issue command and related functionality

authored by markbennett.ca and committed by tangled.org f9b721b5 b4372da4

+15 -370
-72
src/commands/issue.ts
··· 1 - import { confirm } from '@inquirer/prompts'; 2 1 import { Command } from 'commander'; 3 2 import type { TangledApiClient } from '../lib/api-client.js'; 4 3 import { createApiClient } from '../lib/api-client.js'; ··· 7 6 import { 8 7 closeIssue, 9 8 createIssue, 10 - deleteIssue, 11 9 getCompleteIssueData, 12 10 getIssueState, 13 11 listIssues, ··· 368 366 } 369 367 370 368 /** 371 - * Issue delete subcommand 372 - */ 373 - function createDeleteCommand(): Command { 374 - return new IssueCommand('delete') 375 - .description('Delete an issue permanently') 376 - .argument('<issue-id>', 'Issue number or rkey') 377 - .option('-f, --force', 'Skip confirmation prompt') 378 - .addIssueJsonOption() 379 - .action(async (issueId: string, options: { force?: boolean; json?: string | true }) => { 380 - // 1. Validate auth 381 - const client = createApiClient(); 382 - await ensureAuthenticated(client); 383 - 384 - // 2. Get repo context 385 - const context = await getCurrentRepoContext(); 386 - if (!context) { 387 - console.error('✗ Not in a Tangled repository'); 388 - console.error('\nTo use this repository with Tangled, add a remote:'); 389 - console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 390 - process.exit(1); 391 - } 392 - 393 - // 3. Build repo AT-URI, resolve issue ID, and fetch issue details 394 - let issueUri: string; 395 - let displayId: string; 396 - let issueData: IssueData; 397 - try { 398 - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 399 - ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri)); 400 - issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); 401 - } catch (error) { 402 - console.error( 403 - `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` 404 - ); 405 - process.exit(1); 406 - } 407 - 408 - // 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly) 409 - if (!options.force) { 410 - const confirmed = await confirm({ 411 - message: `Are you sure you want to delete issue ${displayId} "${issueData.title}"? This cannot be undone.`, 412 - default: false, 413 - }); 414 - 415 - if (!confirmed) { 416 - console.log('Deletion cancelled.'); 417 - process.exit(0); 418 - } 419 - } 420 - 421 - // 5. Delete issue 422 - try { 423 - await deleteIssue({ client, issueUri }); 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 - } 430 - } catch (error) { 431 - console.error( 432 - `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` 433 - ); 434 - process.exit(1); 435 - } 436 - }); 437 - } 438 - 439 - /** 440 369 * Create the issue command with all subcommands 441 370 */ 442 371 export function createIssueCommand(): Command { ··· 449 378 issue.addCommand(createEditCommand()); 450 379 issue.addCommand(createCloseCommand()); 451 380 issue.addCommand(createReopenCommand()); 452 - issue.addCommand(createDeleteCommand()); 453 381 454 382 return issue; 455 383 }
-43
src/lib/issues-api.ts
··· 73 73 } 74 74 75 75 /** 76 - * Parameters for deleting an issue 77 - */ 78 - export interface DeleteIssueParams { 79 - client: TangledApiClient; 80 - issueUri: string; 81 - } 82 - 83 - /** 84 76 * Parameters for getting issue state 85 77 */ 86 78 export interface GetIssueStateParams { ··· 333 325 throw new Error(`Failed to close issue: ${error.message}`); 334 326 } 335 327 throw new Error('Failed to close issue: Unknown error'); 336 - } 337 - } 338 - 339 - /** 340 - * Delete an issue 341 - */ 342 - export async function deleteIssue(params: DeleteIssueParams): Promise<void> { 343 - const { client, issueUri } = params; 344 - 345 - // Validate authentication 346 - const session = await requireAuth(client); 347 - 348 - // Parse issue URI 349 - const { did, collection, rkey } = parseIssueUri(issueUri); 350 - 351 - // Verify user owns the issue 352 - if (did !== session.did) { 353 - throw new Error('Cannot delete issue: you are not the author'); 354 - } 355 - 356 - try { 357 - // Delete record via AT Protocol 358 - await client.getAgent().com.atproto.repo.deleteRecord({ 359 - repo: did, 360 - collection, 361 - rkey, 362 - }); 363 - } catch (error) { 364 - if (error instanceof Error) { 365 - if (error.message.includes('not found')) { 366 - throw new Error(`Issue not found: ${issueUri}`); 367 - } 368 - throw new Error(`Failed to delete issue: ${error.message}`); 369 - } 370 - throw new Error('Failed to delete issue: Unknown error'); 371 328 } 372 329 } 373 330
-168
tests/commands/issue.test.ts
··· 1201 1201 }); 1202 1202 }); 1203 1203 }); 1204 - 1205 - describe('issue delete command', () => { 1206 - let mockClient: TangledApiClient; 1207 - let consoleLogSpy: ReturnType<typeof vi.spyOn>; 1208 - let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 1209 - let processExitSpy: ReturnType<typeof vi.spyOn>; 1210 - 1211 - const mockIssue: IssueWithMetadata = { 1212 - $type: 'sh.tangled.repo.issue', 1213 - repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 1214 - title: 'Test Issue', 1215 - createdAt: new Date('2024-01-01').toISOString(), 1216 - uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 1217 - cid: 'bafyrei1', 1218 - author: 'did:plc:abc123', 1219 - }; 1220 - 1221 - beforeEach(() => { 1222 - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 1223 - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 1224 - processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 1225 - throw new Error(`process.exit(${code})`); 1226 - }) as never; 1227 - 1228 - mockClient = { 1229 - resumeSession: vi.fn(async () => true), 1230 - } as unknown as TangledApiClient; 1231 - vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 1232 - 1233 - vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 1234 - owner: 'test.bsky.social', 1235 - ownerType: 'handle', 1236 - name: 'test-repo', 1237 - remoteName: 'origin', 1238 - remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 1239 - protocol: 'ssh', 1240 - }); 1241 - 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 - }); 1253 - }); 1254 - 1255 - afterEach(() => { 1256 - vi.restoreAllMocks(); 1257 - }); 1258 - 1259 - it('should delete issue with --force flag', async () => { 1260 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ 1261 - issues: [mockIssue], 1262 - cursor: undefined, 1263 - }); 1264 - vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1265 - 1266 - const command = createIssueCommand(); 1267 - await command.parseAsync(['node', 'test', 'delete', '1', '--force']); 1268 - 1269 - expect(issuesApi.deleteIssue).toHaveBeenCalledWith({ 1270 - client: mockClient, 1271 - issueUri: mockIssue.uri, 1272 - }); 1273 - expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 deleted'); 1274 - expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1275 - }); 1276 - 1277 - it('should cancel deletion when user declines confirmation', async () => { 1278 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ 1279 - issues: [mockIssue], 1280 - cursor: undefined, 1281 - }); 1282 - 1283 - const { confirm } = await import('@inquirer/prompts'); 1284 - vi.mocked(confirm).mockResolvedValue(false); 1285 - 1286 - const command = createIssueCommand(); 1287 - await expect(command.parseAsync(['node', 'test', 'delete', '1'])).rejects.toThrow( 1288 - 'process.exit(0)' 1289 - ); 1290 - 1291 - expect(issuesApi.deleteIssue).not.toHaveBeenCalled(); 1292 - expect(consoleLogSpy).toHaveBeenCalledWith('Deletion cancelled.'); 1293 - expect(processExitSpy).toHaveBeenCalledWith(0); 1294 - }); 1295 - 1296 - it('should delete when user confirms', async () => { 1297 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ 1298 - issues: [mockIssue], 1299 - cursor: undefined, 1300 - }); 1301 - vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1302 - 1303 - const { confirm } = await import('@inquirer/prompts'); 1304 - vi.mocked(confirm).mockResolvedValue(true); 1305 - 1306 - const command = createIssueCommand(); 1307 - await command.parseAsync(['node', 'test', 'delete', '1']); 1308 - 1309 - expect(issuesApi.deleteIssue).toHaveBeenCalled(); 1310 - expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 deleted'); 1311 - expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1312 - }); 1313 - 1314 - it('should fail when not authenticated', async () => { 1315 - vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1316 - console.error('✗ Not authenticated. Run "tangled auth login" first.'); 1317 - process.exit(1); 1318 - }); 1319 - 1320 - const command = createIssueCommand(); 1321 - await expect(command.parseAsync(['node', 'test', 'delete', '1', '--force'])).rejects.toThrow( 1322 - 'process.exit(1)' 1323 - ); 1324 - 1325 - expect(consoleErrorSpy).toHaveBeenCalledWith( 1326 - '✗ Not authenticated. Run "tangled auth login" first.' 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 - }); 1370 - }); 1371 - });
-75
tests/lib/issues-api.test.ts
··· 3 3 import { 4 4 closeIssue, 5 5 createIssue, 6 - deleteIssue, 7 6 getCompleteIssueData, 8 7 getIssue, 9 8 getIssueState, ··· 587 586 closeIssue({ 588 587 client: mockClient, 589 588 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 590 - }) 591 - ).rejects.toThrow('Must be authenticated'); 592 - }); 593 - }); 594 - 595 - describe('deleteIssue', () => { 596 - let mockClient: TangledApiClient; 597 - 598 - beforeEach(() => { 599 - mockClient = createMockClient(true); 600 - }); 601 - 602 - it('should delete an issue', async () => { 603 - const mockDeleteRecord = vi.fn().mockResolvedValue({}); 604 - 605 - vi.mocked(mockClient.getAgent).mockReturnValue({ 606 - com: { 607 - atproto: { 608 - repo: { 609 - deleteRecord: mockDeleteRecord, 610 - }, 611 - }, 612 - }, 613 - } as never); 614 - 615 - await deleteIssue({ 616 - client: mockClient, 617 - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 618 - }); 619 - 620 - expect(mockDeleteRecord).toHaveBeenCalledWith({ 621 - repo: 'did:plc:test123', 622 - collection: 'sh.tangled.repo.issue', 623 - rkey: 'issue1', 624 - }); 625 - }); 626 - 627 - it('should throw error when deleting issue not owned by user', async () => { 628 - await expect( 629 - deleteIssue({ 630 - client: mockClient, 631 - issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', 632 - }) 633 - ).rejects.toThrow('Cannot delete issue: you are not the author'); 634 - }); 635 - 636 - it('should throw error when issue not found', async () => { 637 - const mockDeleteRecord = vi.fn().mockRejectedValue(new Error('Record not found')); 638 - 639 - vi.mocked(mockClient.getAgent).mockReturnValue({ 640 - com: { 641 - atproto: { 642 - repo: { 643 - deleteRecord: mockDeleteRecord, 644 - }, 645 - }, 646 - }, 647 - } as never); 648 - 649 - await expect( 650 - deleteIssue({ 651 - client: mockClient, 652 - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/nonexistent', 653 - }) 654 - ).rejects.toThrow('Issue not found'); 655 - }); 656 - 657 - it('should throw error when not authenticated', async () => { 658 - mockClient = createMockClient(false); 659 - 660 - await expect( 661 - deleteIssue({ 662 - client: mockClient, 663 - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 664 589 }) 665 590 ).rejects.toThrow('Must be authenticated'); 666 591 });
+15 -12
tests/utils/auth-helpers.test.ts
··· 84 84 expect(mockExit).toHaveBeenCalledWith(1); 85 85 }); 86 86 87 - it.skipIf(process.platform !== 'darwin')('should unlock keychain and retry when KeychainAccessError is thrown', async () => { 88 - const mockClient = { 89 - resumeSession: vi 90 - .fn() 91 - .mockRejectedValueOnce(new KeychainAccessError('locked')) 92 - .mockResolvedValueOnce(true), 93 - } as unknown as TangledApiClient; 87 + it.skipIf(process.platform !== 'darwin')( 88 + 'should unlock keychain and retry when KeychainAccessError is thrown', 89 + async () => { 90 + const mockClient = { 91 + resumeSession: vi 92 + .fn() 93 + .mockRejectedValueOnce(new KeychainAccessError('locked')) 94 + .mockResolvedValueOnce(true), 95 + } as unknown as TangledApiClient; 94 96 95 - vi.mocked(execSync).mockReturnValue(Buffer.from('')); 97 + vi.mocked(execSync).mockReturnValue(Buffer.from('')); 96 98 97 - await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); 98 - expect(execSync).toHaveBeenCalledWith('security unlock-keychain', { stdio: 'inherit' }); 99 - expect(mockExit).not.toHaveBeenCalled(); 100 - }); 99 + await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); 100 + expect(execSync).toHaveBeenCalledWith('security unlock-keychain', { stdio: 'inherit' }); 101 + expect(mockExit).not.toHaveBeenCalled(); 102 + } 103 + ); 101 104 102 105 it('should exit with keychain error when unlock fails', async () => { 103 106 const mockClient = {