+17
-372
Diff
round #0
-72
src/commands/issue.ts
-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,
···
367
365
});
368
366
}
369
367
370
-
/**
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
368
/**
440
369
* Create the issue command with all subcommands
441
370
*/
···
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
-43
src/lib/issues-api.ts
···
72
72
issueUri: string;
73
73
}
74
74
75
-
/**
76
-
* Parameters for deleting an issue
77
-
*/
78
-
export interface DeleteIssueParams {
79
-
client: TangledApiClient;
80
-
issueUri: string;
81
-
}
82
-
83
75
/**
84
76
* Parameters for getting issue state
85
77
*/
···
336
328
}
337
329
}
338
330
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
-
}
372
-
}
373
-
374
331
/**
375
332
* Get the state of an issue (open or closed)
376
333
* @returns 'open' or 'closed' (defaults to 'open' if no state record exists)
-168
tests/commands/issue.test.ts
-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
-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,
···
592
591
});
593
592
});
594
593
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
-
})
665
-
).rejects.toThrow('Must be authenticated');
666
-
});
667
-
});
668
-
669
594
describe('getIssueState', () => {
670
595
let mockClient: TangledApiClient;
671
596
+17
-14
tests/utils/auth-helpers.test.ts
+17
-14
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;
94
-
95
-
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
96
-
97
-
await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined();
98
-
expect(execSync).toHaveBeenCalledWith('security unlock-keychain', { stdio: 'inherit' });
99
-
expect(mockExit).not.toHaveBeenCalled();
100
-
});
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;
96
+
97
+
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
98
+
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 = {
History
1 round
1 comment
markbennett.ca
submitted
#0
1 commit
expand
collapse
Remove delete issue command and related functionality
expand 1 comment
pull request successfully merged
LGTM!