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! :)
1import { parseAtUri } from '../utils/at-uri.js';
2import { requireAuth } from '../utils/auth-helpers.js';
3import type { TangledApiClient } from './api-client.js';
4
5/**
6 * Issue record type based on sh.tangled.repo.issue lexicon
7 * @see lexicons/sh/tangled/issue/issue.json
8 */
9export interface IssueRecord {
10 $type: 'sh.tangled.repo.issue';
11 repo: string;
12 title: string;
13 body?: string;
14 createdAt: string;
15 mentions?: string[];
16 references?: string[];
17 [key: string]: unknown;
18}
19
20/**
21 * Issue record with metadata
22 */
23export interface IssueWithMetadata extends IssueRecord {
24 uri: string; // AT-URI of the issue
25 cid: string; // Content ID
26 author: string; // Creator's DID
27}
28
29/**
30 * Parameters for creating an issue
31 */
32export interface CreateIssueParams {
33 client: TangledApiClient;
34 repoAtUri: string;
35 title: string;
36 body?: string;
37}
38
39/**
40 * Parameters for listing issues
41 */
42export interface ListIssuesParams {
43 client: TangledApiClient;
44 repoAtUri: string;
45 limit?: number;
46 cursor?: string;
47}
48
49/**
50 * Parameters for getting a specific issue
51 */
52export interface GetIssueParams {
53 client: TangledApiClient;
54 issueUri: string;
55}
56
57/**
58 * Parameters for updating an issue
59 */
60export interface UpdateIssueParams {
61 client: TangledApiClient;
62 issueUri: string;
63 title?: string;
64 body?: string;
65}
66
67/**
68 * Parameters for closing an issue
69 */
70export interface CloseIssueParams {
71 client: TangledApiClient;
72 issueUri: string;
73}
74
75/**
76 * Parameters for deleting an issue
77 */
78export interface DeleteIssueParams {
79 client: TangledApiClient;
80 issueUri: string;
81}
82
83/**
84 * Parse and validate an issue AT-URI
85 * @throws Error if URI is invalid or missing rkey
86 * @returns Parsed URI components
87 */
88function parseIssueUri(issueUri: string): {
89 did: string;
90 collection: string;
91 rkey: string;
92} {
93 const parsed = parseAtUri(issueUri);
94 if (!parsed || !parsed.rkey) {
95 throw new Error(`Invalid issue AT-URI: ${issueUri}`);
96 }
97
98 return {
99 did: parsed.did,
100 collection: parsed.collection,
101 rkey: parsed.rkey,
102 };
103}
104
105/**
106 * Create a new issue
107 */
108export async function createIssue(params: CreateIssueParams): Promise<IssueWithMetadata> {
109 const { client, repoAtUri, title, body } = params;
110
111 // Validate authentication
112 const session = await requireAuth(client);
113
114 // Build issue record
115 const record: IssueRecord = {
116 $type: 'sh.tangled.repo.issue',
117 repo: repoAtUri,
118 title,
119 body,
120 createdAt: new Date().toISOString(),
121 };
122
123 try {
124 // Create record via AT Protocol
125 const response = await client.getAgent().com.atproto.repo.createRecord({
126 repo: session.did,
127 collection: 'sh.tangled.repo.issue',
128 record,
129 });
130
131 return {
132 ...record,
133 uri: response.data.uri,
134 cid: response.data.cid,
135 author: session.did,
136 };
137 } catch (error) {
138 if (error instanceof Error) {
139 throw new Error(`Failed to create issue: ${error.message}`);
140 }
141 throw new Error('Failed to create issue: Unknown error');
142 }
143}
144
145/**
146 * List issues for a repository
147 */
148export async function listIssues(params: ListIssuesParams): Promise<{
149 issues: IssueWithMetadata[];
150 cursor?: string;
151}> {
152 const { client, repoAtUri, limit = 50, cursor } = params;
153
154 // Validate authentication
155 await requireAuth(client);
156
157 // Extract owner DID from repo AT-URI
158 const parsed = parseAtUri(repoAtUri);
159 if (!parsed) {
160 throw new Error(`Invalid repository AT-URI: ${repoAtUri}`);
161 }
162
163 const ownerDid = parsed.did;
164
165 try {
166 // List all issue records for the owner
167 const response = await client.getAgent().com.atproto.repo.listRecords({
168 repo: ownerDid,
169 collection: 'sh.tangled.repo.issue',
170 limit,
171 cursor,
172 });
173
174 // Filter to only issues for this specific repository
175 const issues: IssueWithMetadata[] = response.data.records
176 .filter((record) => {
177 const issueRecord = record.value as IssueRecord;
178 return issueRecord.repo === repoAtUri;
179 })
180 .map((record) => ({
181 ...(record.value as IssueRecord),
182 uri: record.uri,
183 cid: record.cid,
184 author: ownerDid,
185 }));
186
187 return {
188 issues,
189 cursor: response.data.cursor,
190 };
191 } catch (error) {
192 if (error instanceof Error) {
193 throw new Error(`Failed to list issues: ${error.message}`);
194 }
195 throw new Error('Failed to list issues: Unknown error');
196 }
197}
198
199/**
200 * Get a specific issue
201 */
202export async function getIssue(params: GetIssueParams): Promise<IssueWithMetadata> {
203 const { client, issueUri } = params;
204
205 // Validate authentication
206 await requireAuth(client);
207
208 // Parse issue URI
209 const { did, collection, rkey } = parseIssueUri(issueUri);
210
211 try {
212 // Get record via AT Protocol
213 const response = await client.getAgent().com.atproto.repo.getRecord({
214 repo: did,
215 collection,
216 rkey,
217 });
218
219 const record = response.data.value as IssueRecord;
220
221 return {
222 ...record,
223 uri: response.data.uri,
224 cid: response.data.cid as string, // CID is always present in AT Protocol responses
225 author: did,
226 };
227 } catch (error) {
228 if (error instanceof Error) {
229 if (error.message.includes('not found')) {
230 throw new Error(`Issue not found: ${issueUri}`);
231 }
232 throw new Error(`Failed to get issue: ${error.message}`);
233 }
234 throw new Error('Failed to get issue: Unknown error');
235 }
236}
237
238/**
239 * Update an issue (title and/or body)
240 */
241export async function updateIssue(params: UpdateIssueParams): Promise<IssueWithMetadata> {
242 const { client, issueUri, title, body } = params;
243
244 // Validate authentication
245 const session = await requireAuth(client);
246
247 // Parse issue URI
248 const { did, collection, rkey } = parseIssueUri(issueUri);
249
250 // Verify user owns the issue
251 if (did !== session.did) {
252 throw new Error('Cannot update issue: you are not the author');
253 }
254
255 try {
256 // Get current issue to merge with updates
257 const currentIssue = await getIssue({ client, issueUri });
258
259 // Build updated record (merge existing with new values)
260 const updatedRecord: IssueRecord = {
261 ...currentIssue,
262 ...(title !== undefined && { title }),
263 ...(body !== undefined && { body }),
264 };
265
266 // Update record with CID swap for atomic update
267 const response = await client.getAgent().com.atproto.repo.putRecord({
268 repo: did,
269 collection,
270 rkey,
271 record: updatedRecord,
272 swapRecord: currentIssue.cid,
273 });
274
275 return {
276 ...updatedRecord,
277 uri: issueUri,
278 cid: response.data.cid,
279 author: did,
280 };
281 } catch (error) {
282 if (error instanceof Error) {
283 throw new Error(`Failed to update issue: ${error.message}`);
284 }
285 throw new Error('Failed to update issue: Unknown error');
286 }
287}
288
289/**
290 * Close an issue by creating/updating a state record
291 */
292export async function closeIssue(params: CloseIssueParams): Promise<void> {
293 const { client, issueUri } = params;
294
295 // Validate authentication
296 const session = await requireAuth(client);
297
298 try {
299 // Verify issue exists
300 await getIssue({ client, issueUri });
301
302 // Create state record
303 const stateRecord = {
304 $type: 'sh.tangled.repo.issue.state',
305 issue: issueUri,
306 state: 'sh.tangled.repo.issue.state.closed',
307 };
308
309 // Create state record
310 await client.getAgent().com.atproto.repo.createRecord({
311 repo: session.did,
312 collection: 'sh.tangled.repo.issue.state',
313 record: stateRecord,
314 });
315 } catch (error) {
316 if (error instanceof Error) {
317 throw new Error(`Failed to close issue: ${error.message}`);
318 }
319 throw new Error('Failed to close issue: Unknown error');
320 }
321}
322
323/**
324 * Delete an issue
325 */
326export async function deleteIssue(params: DeleteIssueParams): Promise<void> {
327 const { client, issueUri } = params;
328
329 // Validate authentication
330 const session = await requireAuth(client);
331
332 // Parse issue URI
333 const { did, collection, rkey } = parseIssueUri(issueUri);
334
335 // Verify user owns the issue
336 if (did !== session.did) {
337 throw new Error('Cannot delete issue: you are not the author');
338 }
339
340 try {
341 // Delete record via AT Protocol
342 await client.getAgent().com.atproto.repo.deleteRecord({
343 repo: did,
344 collection,
345 rkey,
346 });
347 } catch (error) {
348 if (error instanceof Error) {
349 if (error.message.includes('not found')) {
350 throw new Error(`Issue not found: ${issueUri}`);
351 }
352 throw new Error(`Failed to delete issue: ${error.message}`);
353 }
354 throw new Error('Failed to delete issue: Unknown error');
355 }
356}