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 * Parameters for getting issue state
85 */
86export interface GetIssueStateParams {
87 client: TangledApiClient;
88 issueUri: string;
89}
90
91/**
92 * Parameters for reopening an issue
93 */
94export interface ReopenIssueParams {
95 client: TangledApiClient;
96 issueUri: string;
97}
98
99/**
100 * Parse and validate an issue AT-URI
101 * @throws Error if URI is invalid or missing rkey
102 * @returns Parsed URI components
103 */
104function parseIssueUri(issueUri: string): {
105 did: string;
106 collection: string;
107 rkey: string;
108} {
109 const parsed = parseAtUri(issueUri);
110 if (!parsed || !parsed.rkey) {
111 throw new Error(`Invalid issue AT-URI: ${issueUri}`);
112 }
113
114 return {
115 did: parsed.did,
116 collection: parsed.collection,
117 rkey: parsed.rkey,
118 };
119}
120
121/**
122 * Create a new issue
123 */
124export async function createIssue(params: CreateIssueParams): Promise<IssueWithMetadata> {
125 const { client, repoAtUri, title, body } = params;
126
127 // Validate authentication
128 const session = await requireAuth(client);
129
130 // Build issue record
131 const record: IssueRecord = {
132 $type: 'sh.tangled.repo.issue',
133 repo: repoAtUri,
134 title,
135 body,
136 createdAt: new Date().toISOString(),
137 };
138
139 try {
140 // Create record via AT Protocol
141 const response = await client.getAgent().com.atproto.repo.createRecord({
142 repo: session.did,
143 collection: 'sh.tangled.repo.issue',
144 record,
145 });
146
147 return {
148 ...record,
149 uri: response.data.uri,
150 cid: response.data.cid,
151 author: session.did,
152 };
153 } catch (error) {
154 if (error instanceof Error) {
155 throw new Error(`Failed to create issue: ${error.message}`);
156 }
157 throw new Error('Failed to create issue: Unknown error');
158 }
159}
160
161/**
162 * List issues for a repository
163 */
164export async function listIssues(params: ListIssuesParams): Promise<{
165 issues: IssueWithMetadata[];
166 cursor?: string;
167}> {
168 const { client, repoAtUri, limit = 50, cursor } = params;
169
170 // Validate authentication
171 await requireAuth(client);
172
173 // Extract owner DID from repo AT-URI
174 const parsed = parseAtUri(repoAtUri);
175 if (!parsed) {
176 throw new Error(`Invalid repository AT-URI: ${repoAtUri}`);
177 }
178
179 const ownerDid = parsed.did;
180
181 try {
182 // List all issue records for the owner
183 const response = await client.getAgent().com.atproto.repo.listRecords({
184 repo: ownerDid,
185 collection: 'sh.tangled.repo.issue',
186 limit,
187 cursor,
188 });
189
190 // Filter to only issues for this specific repository
191 const issues: IssueWithMetadata[] = response.data.records
192 .filter((record) => {
193 const issueRecord = record.value as IssueRecord;
194 return issueRecord.repo === repoAtUri;
195 })
196 .map((record) => ({
197 ...(record.value as IssueRecord),
198 uri: record.uri,
199 cid: record.cid,
200 author: ownerDid,
201 }));
202
203 return {
204 issues,
205 cursor: response.data.cursor,
206 };
207 } catch (error) {
208 if (error instanceof Error) {
209 throw new Error(`Failed to list issues: ${error.message}`);
210 }
211 throw new Error('Failed to list issues: Unknown error');
212 }
213}
214
215/**
216 * Get a specific issue
217 */
218export async function getIssue(params: GetIssueParams): Promise<IssueWithMetadata> {
219 const { client, issueUri } = params;
220
221 // Validate authentication
222 await requireAuth(client);
223
224 // Parse issue URI
225 const { did, collection, rkey } = parseIssueUri(issueUri);
226
227 try {
228 // Get record via AT Protocol
229 const response = await client.getAgent().com.atproto.repo.getRecord({
230 repo: did,
231 collection,
232 rkey,
233 });
234
235 const record = response.data.value as IssueRecord;
236
237 return {
238 ...record,
239 uri: response.data.uri,
240 cid: response.data.cid as string, // CID is always present in AT Protocol responses
241 author: did,
242 };
243 } catch (error) {
244 if (error instanceof Error) {
245 if (error.message.includes('not found')) {
246 throw new Error(`Issue not found: ${issueUri}`);
247 }
248 throw new Error(`Failed to get issue: ${error.message}`);
249 }
250 throw new Error('Failed to get issue: Unknown error');
251 }
252}
253
254/**
255 * Update an issue (title and/or body)
256 */
257export async function updateIssue(params: UpdateIssueParams): Promise<IssueWithMetadata> {
258 const { client, issueUri, title, body } = params;
259
260 // Validate authentication
261 const session = await requireAuth(client);
262
263 // Parse issue URI
264 const { did, collection, rkey } = parseIssueUri(issueUri);
265
266 // Verify user owns the issue
267 if (did !== session.did) {
268 throw new Error('Cannot update issue: you are not the author');
269 }
270
271 try {
272 // Get current issue to merge with updates
273 const currentIssue = await getIssue({ client, issueUri });
274
275 // Build updated record (merge existing with new values)
276 const updatedRecord: IssueRecord = {
277 ...currentIssue,
278 ...(title !== undefined && { title }),
279 ...(body !== undefined && { body }),
280 };
281
282 // Update record with CID swap for atomic update
283 const response = await client.getAgent().com.atproto.repo.putRecord({
284 repo: did,
285 collection,
286 rkey,
287 record: updatedRecord,
288 swapRecord: currentIssue.cid,
289 });
290
291 return {
292 ...updatedRecord,
293 uri: issueUri,
294 cid: response.data.cid,
295 author: did,
296 };
297 } catch (error) {
298 if (error instanceof Error) {
299 throw new Error(`Failed to update issue: ${error.message}`);
300 }
301 throw new Error('Failed to update issue: Unknown error');
302 }
303}
304
305/**
306 * Close an issue by creating/updating a state record
307 */
308export async function closeIssue(params: CloseIssueParams): Promise<void> {
309 const { client, issueUri } = params;
310
311 // Validate authentication
312 const session = await requireAuth(client);
313
314 try {
315 // Verify issue exists
316 await getIssue({ client, issueUri });
317
318 // Create state record
319 const stateRecord = {
320 $type: 'sh.tangled.repo.issue.state',
321 issue: issueUri,
322 state: 'sh.tangled.repo.issue.state.closed',
323 };
324
325 // Create state record
326 await client.getAgent().com.atproto.repo.createRecord({
327 repo: session.did,
328 collection: 'sh.tangled.repo.issue.state',
329 record: stateRecord,
330 });
331 } catch (error) {
332 if (error instanceof Error) {
333 throw new Error(`Failed to close issue: ${error.message}`);
334 }
335 throw new Error('Failed to close issue: Unknown error');
336 }
337}
338
339/**
340 * Delete an issue
341 */
342export 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/**
375 * Get the state of an issue (open or closed)
376 * @returns 'open' or 'closed' (defaults to 'open' if no state record exists)
377 */
378export async function getIssueState(params: GetIssueStateParams): Promise<'open' | 'closed'> {
379 const { client, issueUri } = params;
380
381 // Validate authentication
382 await requireAuth(client);
383
384 // Parse issue URI to get author DID
385 const { did } = parseIssueUri(issueUri);
386
387 try {
388 // Query state records for the issue author
389 const response = await client.getAgent().com.atproto.repo.listRecords({
390 repo: did,
391 collection: 'sh.tangled.repo.issue.state',
392 limit: 100,
393 });
394
395 // Filter to find state records for this specific issue
396 const stateRecords = response.data.records.filter((record) => {
397 const stateData = record.value as { issue?: string };
398 return stateData.issue === issueUri;
399 });
400
401 if (stateRecords.length === 0) {
402 // No state record found - default to open
403 return 'open';
404 }
405
406 // Get the most recent state record (AT Protocol records are sorted by index)
407 const latestState = stateRecords[stateRecords.length - 1];
408 const stateData = latestState.value as {
409 state?: 'sh.tangled.repo.issue.state.open' | 'sh.tangled.repo.issue.state.closed';
410 };
411
412 // Return 'open' or 'closed' based on the state type
413 if (stateData.state === 'sh.tangled.repo.issue.state.closed') {
414 return 'closed';
415 }
416
417 return 'open';
418 } catch (error) {
419 if (error instanceof Error) {
420 throw new Error(`Failed to get issue state: ${error.message}`);
421 }
422 throw new Error('Failed to get issue state: Unknown error');
423 }
424}
425
426/**
427 * Resolve a sequential issue number from a displayId or by scanning the issue list.
428 * Fast path: if displayId is "#N", return N directly.
429 * Fallback: fetch all issues, sort oldest-first, return 1-based position.
430 */
431export async function resolveSequentialNumber(
432 displayId: string,
433 issueUri: string,
434 client: TangledApiClient,
435 repoAtUri: string
436): Promise<number | undefined> {
437 const match = displayId.match(/^#(\d+)$/);
438 if (match) return Number.parseInt(match[1], 10);
439
440 const { issues } = await listIssues({ client, repoAtUri, limit: 100 });
441 const sorted = issues.sort(
442 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
443 );
444 const idx = sorted.findIndex((i) => i.uri === issueUri);
445 return idx >= 0 ? idx + 1 : undefined;
446}
447
448/**
449 * Canonical JSON shape for a single issue, used by all issue commands.
450 */
451export interface IssueData {
452 number: number | undefined;
453 title: string;
454 body?: string;
455 state: 'open' | 'closed';
456 author: string;
457 createdAt: string;
458 uri: string;
459 cid: string;
460}
461
462/**
463 * Fetch a complete IssueData object ready for JSON output.
464 * Fetches the issue record and sequential number in parallel.
465 * If stateOverride is supplied (e.g. 'closed' after a close operation),
466 * getIssueState is skipped; otherwise the current state is fetched.
467 */
468export async function getCompleteIssueData(
469 client: TangledApiClient,
470 issueUri: string,
471 displayId: string,
472 repoAtUri: string,
473 stateOverride?: 'open' | 'closed'
474): Promise<IssueData> {
475 const [issue, number] = await Promise.all([
476 getIssue({ client, issueUri }),
477 resolveSequentialNumber(displayId, issueUri, client, repoAtUri),
478 ]);
479 const state = stateOverride ?? (await getIssueState({ client, issueUri }));
480 return {
481 number,
482 title: issue.title,
483 body: issue.body,
484 state,
485 author: issue.author,
486 createdAt: issue.createdAt,
487 uri: issue.uri,
488 cid: issue.cid,
489 };
490}
491
492/**
493 * Reopen a closed issue by creating an open state record
494 */
495export async function reopenIssue(params: ReopenIssueParams): Promise<void> {
496 const { client, issueUri } = params;
497
498 // Validate authentication
499 const session = await requireAuth(client);
500
501 try {
502 // Verify issue exists
503 await getIssue({ client, issueUri });
504
505 // Create state record with open state
506 const stateRecord = {
507 $type: 'sh.tangled.repo.issue.state',
508 issue: issueUri,
509 state: 'sh.tangled.repo.issue.state.open',
510 };
511
512 // Create state record
513 await client.getAgent().com.atproto.repo.createRecord({
514 repo: session.did,
515 collection: 'sh.tangled.repo.issue.state',
516 record: stateRecord,
517 });
518 } catch (error) {
519 if (error instanceof Error) {
520 throw new Error(`Failed to reopen issue: ${error.message}`);
521 }
522 throw new Error('Failed to reopen issue: Unknown error');
523 }
524}