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! :)
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 9cb935b9f63383c8d28e48d8379a4b0fdcf0ffbd 356 lines 8.6 kB view raw
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}