Something went wrong. Try again.
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 fro
Something went wrong. Try again.
40 kB · 1151 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';import { createIssueCommand } from '../../src/commands/issue.js';import * as apiClient from '../../src/lib/api-client.js';import type { TangledApiClient } from '../../src/lib/api-client.js';import * as context from '../../src/lib/context.js';import * as issuesApi from '../../src/lib/issues-api.js';import type { IssueWithMetadata } from '../../src/lib/issues-api.js';import * as atUri from '../../src/utils/at-uri.js';import * as authHelpers from '../../src/utils/auth-helpers.js';import * as bodyInput from '../../src/utils/body-input.js';
// Mock dependenciesvi.mock('../../src/lib/api-client.js');vi.mock('../../src/lib/issues-api.js');vi.mock('../../src/lib/context.js');vi.mock('../../src/utils/at-uri.js');vi.mock('../../src/utils/body-input.js');vi.mock('../../src/utils/auth-helpers.js');vi.mock('@inquirer/prompts');
describe('issue create command', () => { let mockClient: TangledApiClient; let consoleLogSpy: ReturnType<typeof vi.spyOn>; let consoleErrorSpy: ReturnType<typeof vi.spyOn>; let processExitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { // Mock console methods consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`); }) as never;
// Mock API client mockClient = { resumeSession: vi.fn(async () => true), } as unknown as TangledApiClient; vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient);
// Mock context vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ owner: 'test.bsky.social', ownerType: 'handle', name: 'test-repo', remoteName: 'origin', remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', protocol: 'ssh', });
// Mock AT-URI builder vi.mocked(atUri.buildRepoAtUri).mockResolvedValue( 'at://did:plc:abc123/sh.tangled.repo/test-repo' );
// Mock body input vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); });
afterEach(() => { vi.restoreAllMocks(); });
describe('create with --body flag', () => { it('should create issue with body text', async () => { const mockIssue: IssueWithMetadata = { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', title: 'Test Issue', body: 'Test body', createdAt: new Date().toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', cid: 'bafyreiabc123', author: 'did:plc:abc123', };
vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Test body'); vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue);
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', 'Test body']);
expect(issuesApi.createIssue).toHaveBeenCalledWith({ client: mockClient, repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', title: 'Test Issue', body: 'Test body', });
expect(consoleLogSpy).toHaveBeenCalledWith('Creating issue...'); expect(consoleLogSpy).toHaveBeenCalledWith('\n✓ Issue created: #abc123'); }); });
describe('create with --body-file flag', () => { it('should create issue with body from file', async () => { const mockIssue: IssueWithMetadata = { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', title: 'Test Issue', body: 'Body from file', createdAt: new Date().toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789', cid: 'bafyreixyz789', author: 'did:plc:abc123', };
vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Body from file'); vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue);
const command = createIssueCommand(); await command.parseAsync([ 'node', 'test', 'create', 'Test Issue', '--body-file', '/tmp/body.txt', ]);
expect(bodyInput.readBodyInput).toHaveBeenCalledWith(undefined, '/tmp/body.txt'); expect(issuesApi.createIssue).toHaveBeenCalledWith({ client: mockClient, repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', title: 'Test Issue', body: 'Body from file', }); }); });
describe('create without body', () => { it('should create issue without body', async () => { const mockIssue: IssueWithMetadata = { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', title: 'Test Issue', createdAt: new Date().toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/test123', cid: 'bafyreitest123', author: 'did:plc:abc123', };
vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue);
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'create', 'Test Issue']);
expect(issuesApi.createIssue).toHaveBeenCalledWith({ client: mockClient, repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', title: 'Test Issue', body: undefined, }); }); });
describe('authentication required', () => { it('should fail when not authenticated', async () => { vi.mocked(mockClient.resumeSession).mockResolvedValue(false);
const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith( '✗ Not authenticated. Run "tangled auth login" first.' ); expect(processExitSpy).toHaveBeenCalledWith(1); }); });
describe('context required', () => { it('should fail when not in a Tangled repository', async () => { vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null);
const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); expect(processExitSpy).toHaveBeenCalledWith(1); }); });
describe('validation errors', () => { it('should fail with empty title', async () => { const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'create', ''])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Issue title cannot be empty') ); expect(processExitSpy).toHaveBeenCalledWith(1); });
it('should fail with title over 256 characters', async () => { const longTitle = 'A'.repeat(257);
const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'create', longTitle])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Issue title must be 256 characters or less') ); expect(processExitSpy).toHaveBeenCalledWith(1); });
it('should fail with body over 50,000 characters', async () => { const longBody = 'A'.repeat(50001);
vi.mocked(bodyInput.readBodyInput).mockResolvedValue(longBody);
const command = createIssueCommand();
await expect( command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', longBody]) ).rejects.toThrow('process.exit(1)');
expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Issue body must be 50,000 characters or less') ); expect(processExitSpy).toHaveBeenCalledWith(1); }); });
describe('API errors', () => { it('should handle API errors gracefully', async () => { vi.mocked(issuesApi.createIssue).mockRejectedValue(new Error('Network error'));
const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Failed to create issue: Network error'); expect(processExitSpy).toHaveBeenCalledWith(1); }); });
describe('JSON output', () => { const mockIssue: IssueWithMetadata = { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', title: 'Test Issue', body: 'Test body', createdAt: '2024-01-01T00:00:00.000Z', uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', cid: 'bafyreiabc123', author: 'did:plc:abc123', };
it('should output JSON of created issue when --json is passed', async () => { vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue);
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json']);
// Should NOT print human-readable messages expect(consoleLogSpy).not.toHaveBeenCalledWith('Creating issue...');
const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); expect(jsonOutput).toMatchObject({ title: 'Test Issue', body: 'Test body', author: 'did:plc:abc123', uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', cid: 'bafyreiabc123', }); });
it('should output filtered JSON when --json with fields is passed', async () => { vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue);
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'title,uri']);
const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); expect(jsonOutput).toEqual({ title: 'Test Issue', uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', }); expect(jsonOutput).not.toHaveProperty('body'); expect(jsonOutput).not.toHaveProperty('author'); }); });});
describe('issue list command', () => { let mockClient: TangledApiClient; let consoleLogSpy: ReturnType<typeof vi.spyOn>; let consoleErrorSpy: ReturnType<typeof vi.spyOn>; let processExitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { // Mock console methods consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`); }) as never;
// Mock API client mockClient = { resumeSession: vi.fn(async () => true), } as unknown as TangledApiClient; vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient);
// Mock context vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ owner: 'test.bsky.social', ownerType: 'handle', name: 'test-repo', remoteName: 'origin', remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', protocol: 'ssh', });
// Mock AT-URI builder vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); });
afterEach(() => { vi.restoreAllMocks(); });
describe('list issues', () => { it('should list issues successfully', async () => { const mockIssues: IssueWithMetadata[] = [ { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', title: 'First Issue', createdAt: new Date('2024-01-01').toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', cid: 'bafyrei1', author: 'did:plc:abc123', }, { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', title: 'Second Issue', createdAt: new Date('2024-01-02').toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', cid: 'bafyrei2', author: 'did:plc:abc123', }, ];
vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: mockIssues, cursor: undefined, }); vi.mocked(issuesApi.getIssueState).mockResolvedValue('open');
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'list']);
expect(issuesApi.listIssues).toHaveBeenCalledWith({ client: mockClient, repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', limit: 50, });
expect(consoleLogSpy).toHaveBeenCalledWith('\nFound 2 issues:\n'); expect(consoleLogSpy).toHaveBeenCalledWith(' #1 [OPEN] First Issue'); expect(consoleLogSpy).toHaveBeenCalledWith(' #2 [OPEN] Second Issue'); });
it('should handle custom limit', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined, });
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'list', '--limit', '25']);
expect(issuesApi.listIssues).toHaveBeenCalledWith({ client: mockClient, repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', limit: 25, }); });
it('should handle empty issue list', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined, });
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'list']);
expect(consoleLogSpy).toHaveBeenCalledWith('No issues found for this repository.'); }); });
describe('authentication required', () => { it('should fail when not authenticated', async () => { vi.mocked(mockClient.resumeSession).mockResolvedValue(false);
const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)');
expect(consoleErrorSpy).toHaveBeenCalledWith( '✗ Not authenticated. Run "tangled auth login" first.' ); expect(processExitSpy).toHaveBeenCalledWith(1); }); });
describe('context required', () => { it('should fail when not in a Tangled repository', async () => { vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null);
const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)');
expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); expect(processExitSpy).toHaveBeenCalledWith(1); }); });
describe('validation errors', () => { it('should fail with invalid limit (too low)', async () => { const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'list', '--limit', '0'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); expect(processExitSpy).toHaveBeenCalledWith(1); });
it('should fail with invalid limit (too high)', async () => { const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'list', '--limit', '101'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); expect(processExitSpy).toHaveBeenCalledWith(1); });
it('should fail with non-numeric limit', async () => { const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'list', '--limit', 'abc'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); expect(processExitSpy).toHaveBeenCalledWith(1); }); });
describe('API errors', () => { it('should handle API errors gracefully', async () => { vi.mocked(issuesApi.listIssues).mockRejectedValue(new Error('Network error'));
const command = createIssueCommand();
await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)');
expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Failed to list issues: Network error'); expect(processExitSpy).toHaveBeenCalledWith(1); }); });
describe('JSON output', () => { const mockIssues: IssueWithMetadata[] = [ { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', title: 'First Issue', body: 'First body', createdAt: new Date('2024-01-01').toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', cid: 'bafyrei1', author: 'did:plc:abc123', }, { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', title: 'Second Issue', createdAt: new Date('2024-01-02').toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', cid: 'bafyrei2', author: 'did:plc:abc123', }, ];
beforeEach(() => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: mockIssues, cursor: undefined, }); vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); });
it('should output JSON array when --json is passed', async () => { const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'list', '--json']);
const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); expect(Array.isArray(jsonOutput)).toBe(true); expect(jsonOutput).toHaveLength(2); expect(jsonOutput[0]).toMatchObject({ number: 1, title: 'First Issue', state: 'open', author: 'did:plc:abc123', }); expect(jsonOutput[1]).toMatchObject({ number: 2, title: 'Second Issue' }); });
it('should output filtered JSON when --json with fields is passed', async () => { const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'list', '--json', 'number,title,state']);
const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); expect(jsonOutput[0]).toEqual({ number: 1, title: 'First Issue', state: 'open' }); expect(jsonOutput[0]).not.toHaveProperty('author'); expect(jsonOutput[0]).not.toHaveProperty('uri'); });
it('should output empty JSON array when no issues exist', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined });
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'list', '--json']);
expect(consoleLogSpy).toHaveBeenCalledWith('[]'); }); });});
describe('issue view command', () => { let mockClient: TangledApiClient; let consoleLogSpy: ReturnType<typeof vi.spyOn>; let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
const mockIssue: IssueWithMetadata = { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', title: 'Test Issue', body: 'Issue body', createdAt: new Date('2024-01-01').toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', cid: 'bafyrei1', author: 'did:plc:abc123', };
beforeEach(() => { consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`); }) as never;
mockClient = { resumeSession: vi.fn(async () => true), } as unknown as TangledApiClient; vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient);
vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ owner: 'test.bsky.social', ownerType: 'handle', name: 'test-repo', remoteName: 'origin', remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', protocol: 'ssh', });
vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789');
vi.mocked(authHelpers.requireAuth).mockResolvedValue({ did: 'did:plc:abc123', handle: 'test.bsky.social', } as never); });
afterEach(() => { vi.restoreAllMocks(); });
it('should view issue by number', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); vi.mocked(issuesApi.getIssueState).mockResolvedValue('open');
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'view', '1']);
expect(issuesApi.getIssue).toHaveBeenCalledWith({ client: mockClient, issueUri: mockIssue.uri, }); expect(issuesApi.getIssueState).toHaveBeenCalledWith({ client: mockClient, issueUri: mockIssue.uri, }); expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue #1 [OPEN]'); expect(consoleLogSpy).toHaveBeenCalledWith('Title: Test Issue'); expect(consoleLogSpy).toHaveBeenCalledWith('\nBody:'); expect(consoleLogSpy).toHaveBeenCalledWith('Issue body'); });
it('should view issue by rkey', async () => { vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed');
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'view', 'issue1']);
expect(issuesApi.getIssue).toHaveBeenCalledWith({ client: mockClient, issueUri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', }); expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue issue1 [CLOSED]'); });
it('should show issue without body', async () => { const issueWithoutBody = { ...mockIssue, body: undefined }; vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [issueWithoutBody], cursor: undefined, }); vi.mocked(issuesApi.getIssue).mockResolvedValue(issueWithoutBody); vi.mocked(issuesApi.getIssueState).mockResolvedValue('open');
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'view', '1']);
const allCalls = consoleLogSpy.mock.calls.map((c) => c[0]); expect(allCalls).not.toContain('Body:'); });
it('should fail when not authenticated', async () => { vi.mocked(mockClient.resumeSession).mockResolvedValue(false);
const command = createIssueCommand(); await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith( '✗ Not authenticated. Run "tangled auth login" first.' ); });
it('should fail when not in a Tangled repository', async () => { vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null);
const command = createIssueCommand(); await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); });
it('should fail when issue number is out of range', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, });
const command = createIssueCommand(); await expect(command.parseAsync(['node', 'test', 'view', '99'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Issue #99 not found')); });
describe('JSON output', () => { it('should output JSON when --json is passed', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); vi.mocked(issuesApi.getIssueState).mockResolvedValue('open');
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'view', '1', '--json']);
const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); expect(jsonOutput).toMatchObject({ title: 'Test Issue', body: 'Issue body', state: 'open', author: 'did:plc:abc123', uri: mockIssue.uri, cid: mockIssue.cid, }); });
it('should output filtered JSON when --json with fields is passed', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed');
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'view', '1', '--json', 'title,state']);
const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); expect(jsonOutput).toEqual({ title: 'Test Issue', state: 'closed' }); expect(jsonOutput).not.toHaveProperty('body'); expect(jsonOutput).not.toHaveProperty('author'); }); });});
describe('issue edit command', () => { let mockClient: TangledApiClient; let consoleLogSpy: ReturnType<typeof vi.spyOn>; let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
const mockIssue: IssueWithMetadata = { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', title: 'Original Title', createdAt: new Date('2024-01-01').toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', cid: 'bafyrei1', author: 'did:plc:abc123', };
beforeEach(() => { consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`); }) as never;
mockClient = { resumeSession: vi.fn(async () => true), } as unknown as TangledApiClient; vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient);
vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ owner: 'test.bsky.social', ownerType: 'handle', name: 'test-repo', remoteName: 'origin', remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', protocol: 'ssh', });
vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789');
vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); vi.mocked(authHelpers.requireAuth).mockResolvedValue({ did: 'did:plc:abc123', handle: 'test.bsky.social', } as never); });
afterEach(() => { vi.restoreAllMocks(); });
it('should edit issue title by number', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(issuesApi.updateIssue).mockResolvedValue({ ...mockIssue, title: 'New Title' });
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title']);
expect(issuesApi.updateIssue).toHaveBeenCalledWith({ client: mockClient, issueUri: mockIssue.uri, title: 'New Title', body: undefined, }); expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 updated'); expect(consoleLogSpy).toHaveBeenCalledWith(' Updated: title'); });
it('should edit issue body', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(bodyInput.readBodyInput).mockResolvedValue('New body'); vi.mocked(issuesApi.updateIssue).mockResolvedValue({ ...mockIssue, body: 'New body' });
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'edit', '1', '--body', 'New body']);
expect(issuesApi.updateIssue).toHaveBeenCalledWith({ client: mockClient, issueUri: mockIssue.uri, title: undefined, body: 'New body', }); expect(consoleLogSpy).toHaveBeenCalledWith(' Updated: body'); });
it('should fail when no options provided', async () => { const command = createIssueCommand(); await expect(command.parseAsync(['node', 'test', 'edit', '1'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith( '✗ At least one of --title, --body, or --body-file must be provided' ); });
it('should fail when not authenticated', async () => { vi.mocked(mockClient.resumeSession).mockResolvedValue(false);
const command = createIssueCommand(); await expect( command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New']) ).rejects.toThrow('process.exit(1)');
expect(consoleErrorSpy).toHaveBeenCalledWith( '✗ Not authenticated. Run "tangled auth login" first.' ); });
describe('JSON output', () => { it('should output JSON of updated issue when --json is passed', async () => { const updatedIssue = { ...mockIssue, title: 'New Title' }; vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue);
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title', '--json']);
const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); expect(jsonOutput).toMatchObject({ title: 'New Title', author: 'did:plc:abc123', uri: mockIssue.uri, cid: mockIssue.cid, }); // Human-readable messages should NOT appear expect(consoleLogSpy).not.toHaveBeenCalledWith('✓ Issue #1 updated'); });
it('should output filtered JSON when --json with fields is passed', async () => { const updatedIssue = { ...mockIssue, title: 'New Title' }; vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue);
const command = createIssueCommand(); await command.parseAsync([ 'node', 'test', 'edit', '1', '--title', 'New Title', '--json', 'title,uri', ]);
const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); expect(jsonOutput).toEqual({ title: 'New Title', uri: mockIssue.uri, }); expect(jsonOutput).not.toHaveProperty('author'); }); });});
describe('issue close command', () => { let mockClient: TangledApiClient; let consoleLogSpy: ReturnType<typeof vi.spyOn>;
const mockIssue: IssueWithMetadata = { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', title: 'Test Issue', createdAt: new Date('2024-01-01').toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', cid: 'bafyrei1', author: 'did:plc:abc123', };
beforeEach(() => { consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`); }) as never;
mockClient = { resumeSession: vi.fn(async () => true), } as unknown as TangledApiClient; vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient);
vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ owner: 'test.bsky.social', ownerType: 'handle', name: 'test-repo', remoteName: 'origin', remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', protocol: 'ssh', });
vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); });
afterEach(() => { vi.restoreAllMocks(); });
it('should close issue by number', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined);
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'close', '1']);
expect(issuesApi.closeIssue).toHaveBeenCalledWith({ client: mockClient, issueUri: mockIssue.uri, }); expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 closed'); });
it('should fail when not authenticated', async () => { vi.mocked(mockClient.resumeSession).mockResolvedValue(false);
const command = createIssueCommand(); await expect(command.parseAsync(['node', 'test', 'close', '1'])).rejects.toThrow( 'process.exit(1)' ); });});
describe('issue reopen command', () => { let mockClient: TangledApiClient; let consoleLogSpy: ReturnType<typeof vi.spyOn>;
const mockIssue: IssueWithMetadata = { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', title: 'Test Issue', createdAt: new Date('2024-01-01').toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', cid: 'bafyrei1', author: 'did:plc:abc123', };
beforeEach(() => { consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`); }) as never;
mockClient = { resumeSession: vi.fn(async () => true), } as unknown as TangledApiClient; vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient);
vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ owner: 'test.bsky.social', ownerType: 'handle', name: 'test-repo', remoteName: 'origin', remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', protocol: 'ssh', });
vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); });
afterEach(() => { vi.restoreAllMocks(); });
it('should reopen issue by number', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined);
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'reopen', '1']);
expect(issuesApi.reopenIssue).toHaveBeenCalledWith({ client: mockClient, issueUri: mockIssue.uri, }); expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 reopened'); });
it('should fail when not authenticated', async () => { vi.mocked(mockClient.resumeSession).mockResolvedValue(false);
const command = createIssueCommand(); await expect(command.parseAsync(['node', 'test', 'reopen', '1'])).rejects.toThrow( 'process.exit(1)' ); });});
describe('issue delete command', () => { let mockClient: TangledApiClient; let consoleLogSpy: ReturnType<typeof vi.spyOn>; let consoleErrorSpy: ReturnType<typeof vi.spyOn>; let processExitSpy: ReturnType<typeof vi.spyOn>;
const mockIssue: IssueWithMetadata = { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', title: 'Test Issue', createdAt: new Date('2024-01-01').toISOString(), uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', cid: 'bafyrei1', author: 'did:plc:abc123', };
beforeEach(() => { consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`); }) as never;
mockClient = { resumeSession: vi.fn(async () => true), } as unknown as TangledApiClient; vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient);
vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ owner: 'test.bsky.social', ownerType: 'handle', name: 'test-repo', remoteName: 'origin', remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', protocol: 'ssh', });
vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); });
afterEach(() => { vi.restoreAllMocks(); });
it('should delete issue with --force flag', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined);
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'delete', '1', '--force']);
expect(issuesApi.deleteIssue).toHaveBeenCalledWith({ client: mockClient, issueUri: mockIssue.uri, }); expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 deleted'); });
it('should cancel deletion when user declines confirmation', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, });
const { confirm } = await import('@inquirer/prompts'); vi.mocked(confirm).mockResolvedValue(false);
const command = createIssueCommand(); await expect(command.parseAsync(['node', 'test', 'delete', '1'])).rejects.toThrow( 'process.exit(0)' );
expect(issuesApi.deleteIssue).not.toHaveBeenCalled(); expect(consoleLogSpy).toHaveBeenCalledWith('Deletion cancelled.'); expect(processExitSpy).toHaveBeenCalledWith(0); });
it('should delete when user confirms', async () => { vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined, }); vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined);
const { confirm } = await import('@inquirer/prompts'); vi.mocked(confirm).mockResolvedValue(true);
const command = createIssueCommand(); await command.parseAsync(['node', 'test', 'delete', '1']);
expect(issuesApi.deleteIssue).toHaveBeenCalled(); expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 deleted'); });
it('should fail when not authenticated', async () => { vi.mocked(mockClient.resumeSession).mockResolvedValue(false);
const command = createIssueCommand(); await expect(command.parseAsync(['node', 'test', 'delete', '1', '--force'])).rejects.toThrow( 'process.exit(1)' );
expect(consoleErrorSpy).toHaveBeenCalledWith( '✗ Not authenticated. Run "tangled auth login" first.' ); });});