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 f4056f7901fca651977b2b95bfefefe9e685a00a 353 lines 8.1 kB view raw
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}