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 ac6cce98d5bb1720650ca6bb1f07b6ce41c56bad 481 lines 12 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 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}