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