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.
23 kB · 865 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866import { beforeEach, describe, expect, it, vi } from 'vitest';import type { TangledApiClient } from '../../src/lib/api-client.js';import { closeIssue, createIssue, deleteIssue, getIssue, getIssueState, listIssues, reopenIssue, updateIssue,} from '../../src/lib/issues-api.js';
// Mock API client factoryconst createMockClient = (authenticated = true): TangledApiClient => { const mockAgent = { com: { atproto: { repo: { createRecord: vi.fn(), listRecords: vi.fn(), getRecord: vi.fn(), putRecord: vi.fn(), deleteRecord: vi.fn(), }, }, }, };
return { isAuthenticated: vi.fn(async () => authenticated), getSession: vi.fn(() => authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null ), getAgent: vi.fn(() => mockAgent), } as unknown as TangledApiClient;};
describe('createIssue', () => { let mockClient: TangledApiClient;
beforeEach(() => { mockClient = createMockClient(true); });
it('should create an issue with all fields', async () => { const mockCreateRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', cid: 'cid123', }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { createRecord: mockCreateRecord, }, }, }, } as never);
const result = await createIssue({ client: mockClient, repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Bug: Login fails', body: 'Detailed description of the bug', });
expect(result).toMatchObject({ repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Bug: Login fails', body: 'Detailed description of the bug', uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', cid: 'cid123', author: 'did:plc:test123', });
expect(mockCreateRecord).toHaveBeenCalledWith({ repo: 'did:plc:test123', collection: 'sh.tangled.repo.issue', record: expect.objectContaining({ $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Bug: Login fails', body: 'Detailed description of the bug', createdAt: expect.any(String), }), }); });
it('should create an issue without body', async () => { const mockCreateRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', cid: 'cid123', }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { createRecord: mockCreateRecord, }, }, }, } as never);
const result = await createIssue({ client: mockClient, repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Simple issue', });
expect(result.body).toBeUndefined(); expect(mockCreateRecord).toHaveBeenCalled(); });
it('should throw error when not authenticated', async () => { mockClient = createMockClient(false);
await expect( createIssue({ client: mockClient, repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Test', }) ).rejects.toThrow('Must be authenticated'); });
it('should throw error on API failure', async () => { const mockCreateRecord = vi.fn().mockRejectedValue(new Error('API error'));
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { createRecord: mockCreateRecord, }, }, }, } as never);
await expect( createIssue({ client: mockClient, repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Test', }) ).rejects.toThrow('Failed to create issue: API error'); });});
describe('listIssues', () => { let mockClient: TangledApiClient;
beforeEach(() => { mockClient = createMockClient(true); });
it('should list issues for a repository', async () => { const mockListRecords = vi.fn().mockResolvedValue({ data: { records: [ { uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', cid: 'cid1', value: { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Issue 1', body: 'Description 1', createdAt: '2024-01-01T00:00:00.000Z', }, }, { uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', cid: 'cid2', value: { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Issue 2', createdAt: '2024-01-02T00:00:00.000Z', }, }, ], cursor: undefined, }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { listRecords: mockListRecords, }, }, }, } as never);
const result = await listIssues({ client: mockClient, repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', });
expect(result.issues).toHaveLength(2); expect(result.issues[0]).toMatchObject({ title: 'Issue 1', body: 'Description 1', uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', }); });
it('should filter issues by repository', async () => { const mockListRecords = vi.fn().mockResolvedValue({ data: { records: [ { uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', cid: 'cid1', value: { repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Issue 1', createdAt: '2024-01-01T00:00:00.000Z', }, }, { uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', cid: 'cid2', value: { repo: 'at://did:plc:owner/sh.tangled.repo/other-repo', title: 'Issue 2', createdAt: '2024-01-02T00:00:00.000Z', }, }, ], cursor: undefined, }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { listRecords: mockListRecords, }, }, }, } as never);
const result = await listIssues({ client: mockClient, repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', });
// Should only include issue from my-repo, not other-repo expect(result.issues).toHaveLength(1); expect(result.issues[0].title).toBe('Issue 1'); });
it('should return empty array when no issues found', async () => { const mockListRecords = vi.fn().mockResolvedValue({ data: { records: [], cursor: undefined, }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { listRecords: mockListRecords, }, }, }, } as never);
const result = await listIssues({ client: mockClient, repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', });
expect(result.issues).toEqual([]); });
it('should throw error when not authenticated', async () => { mockClient = createMockClient(false);
await expect( listIssues({ client: mockClient, repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', }) ).rejects.toThrow('Must be authenticated'); });
it('should throw error for invalid repo URI', async () => { await expect( listIssues({ client: mockClient, repoAtUri: 'invalid-uri', }) ).rejects.toThrow('Invalid repository AT-URI'); });});
describe('getIssue', () => { let mockClient: TangledApiClient;
beforeEach(() => { mockClient = createMockClient(true); });
it('should get a specific issue', async () => { const mockGetRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', cid: 'cid1', value: { $type: 'sh.tangled.repo.issue', repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Test Issue', body: 'Test Description', createdAt: '2024-01-01T00:00:00.000Z', }, }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { getRecord: mockGetRecord, }, }, }, } as never);
const result = await getIssue({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', });
expect(result).toMatchObject({ title: 'Test Issue', body: 'Test Description', uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', cid: 'cid1', });
expect(mockGetRecord).toHaveBeenCalledWith({ repo: 'did:plc:owner', collection: 'sh.tangled.repo.issue', rkey: 'issue1', }); });
it('should throw error when issue not found', async () => { const mockGetRecord = vi.fn().mockRejectedValue(new Error('Record not found'));
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { getRecord: mockGetRecord, }, }, }, } as never);
await expect( getIssue({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', }) ).rejects.toThrow('Issue not found'); });
it('should throw error for invalid issue URI', async () => { await expect( getIssue({ client: mockClient, issueUri: 'invalid-uri', }) ).rejects.toThrow('Invalid issue AT-URI'); });
it('should throw error when not authenticated', async () => { mockClient = createMockClient(false);
await expect( getIssue({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', }) ).rejects.toThrow('Must be authenticated'); });});
describe('updateIssue', () => { let mockClient: TangledApiClient;
beforeEach(() => { mockClient = createMockClient(true); });
it('should update issue title', async () => { const mockGetRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', cid: 'old-cid', value: { repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', title: 'Old Title', body: 'Original body', createdAt: '2024-01-01T00:00:00.000Z', }, }, });
const mockPutRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', cid: 'new-cid', }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { getRecord: mockGetRecord, putRecord: mockPutRecord, }, }, }, } as never);
const result = await updateIssue({ client: mockClient, issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', title: 'New Title', });
expect(result.title).toBe('New Title'); expect(result.body).toBe('Original body'); // Body unchanged
expect(mockPutRecord).toHaveBeenCalledWith({ repo: 'did:plc:test123', collection: 'sh.tangled.repo.issue', rkey: 'issue1', record: expect.objectContaining({ title: 'New Title', body: 'Original body', }), swapRecord: 'old-cid', }); });
it('should update issue body', async () => { const mockGetRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', cid: 'old-cid', value: { repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', title: 'Title', body: 'Old body', createdAt: '2024-01-01T00:00:00.000Z', }, }, });
const mockPutRecord = vi.fn().mockResolvedValue({ data: { cid: 'new-cid', }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { getRecord: mockGetRecord, putRecord: mockPutRecord, }, }, }, } as never);
const result = await updateIssue({ client: mockClient, issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', body: 'New body', });
expect(result.title).toBe('Title'); // Title unchanged expect(result.body).toBe('New body'); });
it('should throw error when updating issue not owned by user', async () => { await expect( updateIssue({ client: mockClient, issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', title: 'New Title', }) ).rejects.toThrow('Cannot update issue: you are not the author'); });
it('should throw error when not authenticated', async () => { mockClient = createMockClient(false);
await expect( updateIssue({ client: mockClient, issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', title: 'New Title', }) ).rejects.toThrow('Must be authenticated'); });});
describe('closeIssue', () => { let mockClient: TangledApiClient;
beforeEach(() => { mockClient = createMockClient(true); });
it('should close an issue', async () => { const mockGetRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', cid: 'cid1', value: { repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Test Issue', createdAt: '2024-01-01T00:00:00.000Z', }, }, });
const mockCreateRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', cid: 'state-cid', }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { getRecord: mockGetRecord, createRecord: mockCreateRecord, }, }, }, } as never);
await closeIssue({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', });
expect(mockCreateRecord).toHaveBeenCalledWith({ repo: 'did:plc:test123', collection: 'sh.tangled.repo.issue.state', record: { $type: 'sh.tangled.repo.issue.state', issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', state: 'sh.tangled.repo.issue.state.closed', }, }); });
it('should throw error when not authenticated', async () => { mockClient = createMockClient(false);
await expect( closeIssue({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', }) ).rejects.toThrow('Must be authenticated'); });});
describe('deleteIssue', () => { let mockClient: TangledApiClient;
beforeEach(() => { mockClient = createMockClient(true); });
it('should delete an issue', async () => { const mockDeleteRecord = vi.fn().mockResolvedValue({});
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { deleteRecord: mockDeleteRecord, }, }, }, } as never);
await deleteIssue({ client: mockClient, issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', });
expect(mockDeleteRecord).toHaveBeenCalledWith({ repo: 'did:plc:test123', collection: 'sh.tangled.repo.issue', rkey: 'issue1', }); });
it('should throw error when deleting issue not owned by user', async () => { await expect( deleteIssue({ client: mockClient, issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', }) ).rejects.toThrow('Cannot delete issue: you are not the author'); });
it('should throw error when issue not found', async () => { const mockDeleteRecord = vi.fn().mockRejectedValue(new Error('Record not found'));
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { deleteRecord: mockDeleteRecord, }, }, }, } as never);
await expect( deleteIssue({ client: mockClient, issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/nonexistent', }) ).rejects.toThrow('Issue not found'); });
it('should throw error when not authenticated', async () => { mockClient = createMockClient(false);
await expect( deleteIssue({ client: mockClient, issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', }) ).rejects.toThrow('Must be authenticated'); });});
describe('getIssueState', () => { let mockClient: TangledApiClient;
beforeEach(() => { mockClient = createMockClient(true); });
it('should return open when no state records exist', async () => { const mockListRecords = vi.fn().mockResolvedValue({ data: { records: [] }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { listRecords: mockListRecords } } }, } as never);
const result = await getIssueState({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', });
expect(result).toBe('open'); expect(mockListRecords).toHaveBeenCalledWith({ repo: 'did:plc:owner', collection: 'sh.tangled.repo.issue.state', limit: 100, }); });
it('should return closed when latest state record is closed', async () => { const mockListRecords = vi.fn().mockResolvedValue({ data: { records: [ { uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', cid: 'cid1', value: { issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', state: 'sh.tangled.repo.issue.state.closed', }, }, ], }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { listRecords: mockListRecords } } }, } as never);
const result = await getIssueState({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', });
expect(result).toBe('closed'); });
it('should return open when latest state record is open', async () => { const mockListRecords = vi.fn().mockResolvedValue({ data: { records: [ { uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', cid: 'cid1', value: { issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', state: 'sh.tangled.repo.issue.state.closed', }, }, { uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state2', cid: 'cid2', value: { issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', state: 'sh.tangled.repo.issue.state.open', }, }, ], }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { listRecords: mockListRecords } } }, } as never);
const result = await getIssueState({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', });
expect(result).toBe('open'); });
it('should filter state records to only the target issue', async () => { const mockListRecords = vi.fn().mockResolvedValue({ data: { records: [ { uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', cid: 'cid1', value: { issue: 'at://did:plc:owner/sh.tangled.repo.issue/other-issue', state: 'sh.tangled.repo.issue.state.closed', }, }, ], }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { listRecords: mockListRecords } } }, } as never);
// The closed state is for a different issue, so this one should be open const result = await getIssueState({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', });
expect(result).toBe('open'); });
it('should throw error when not authenticated', async () => { mockClient = createMockClient(false);
await expect( getIssueState({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', }) ).rejects.toThrow('Must be authenticated'); });});
describe('reopenIssue', () => { let mockClient: TangledApiClient;
beforeEach(() => { mockClient = createMockClient(true); });
it('should reopen a closed issue', async () => { const mockGetRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', cid: 'cid1', value: { repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Test Issue', createdAt: '2024-01-01T00:00:00.000Z', }, }, });
const mockCreateRecord = vi.fn().mockResolvedValue({ data: { uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', cid: 'state-cid', }, });
vi.mocked(mockClient.getAgent).mockReturnValue({ com: { atproto: { repo: { getRecord: mockGetRecord, createRecord: mockCreateRecord, }, }, }, } as never);
await reopenIssue({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', });
expect(mockCreateRecord).toHaveBeenCalledWith({ repo: 'did:plc:test123', collection: 'sh.tangled.repo.issue.state', record: { $type: 'sh.tangled.repo.issue.state', issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', state: 'sh.tangled.repo.issue.state.open', }, }); });
it('should throw error when not authenticated', async () => { mockClient = createMockClient(false);
await expect( reopenIssue({ client: mockClient, issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', }) ).rejects.toThrow('Must be authenticated'); });});