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