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