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