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

Configure Feed

Select the types of activity you want to include in your feed.

Remove delete issue command and related functionality

authored by

Mark Bennett 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 = {