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 feature/issue-4-pr-create-list-view 479 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'; 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}