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