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 pull-request-format-testing 617 lines 20 kB view raw
1import { confirm } from '@inquirer/prompts'; 2import { Command } from 'commander'; 3import { createApiClient } from '../lib/api-client.js'; 4import type { TangledApiClient } from '../lib/api-client.js'; 5import { getCurrentRepoContext } from '../lib/context.js'; 6import { 7 closeIssue, 8 createIssue, 9 deleteIssue, 10 getIssue, 11 getIssueState, 12 listIssues, 13 reopenIssue, 14 updateIssue, 15} from '../lib/issues-api.js'; 16import { buildRepoAtUri } from '../utils/at-uri.js'; 17import { requireAuth } from '../utils/auth-helpers.js'; 18import { readBodyInput } from '../utils/body-input.js'; 19import { formatDate, formatIssueState, outputJson } from '../utils/formatting.js'; 20import { validateIssueBody, validateIssueTitle } from '../utils/validation.js'; 21 22/** 23 * Extract rkey from AT-URI 24 */ 25function extractRkey(uri: string): string { 26 const parts = uri.split('/'); 27 return parts[parts.length - 1] || 'unknown'; 28} 29 30/** 31 * Resolve issue number or rkey to full AT-URI 32 * @param input - User input: number ("1"), hash ("#1"), or rkey ("3mef...") 33 * @param client - API client 34 * @param repoAtUri - Repository AT-URI 35 * @returns Object with full issue AT-URI and display identifier 36 */ 37async function resolveIssueUri( 38 input: string, 39 client: TangledApiClient, 40 repoAtUri: string 41): Promise<{ uri: string; displayId: string }> { 42 // Strip # prefix if present 43 const normalized = input.startsWith('#') ? input.slice(1) : input; 44 45 // Check if numeric 46 if (/^\d+$/.test(normalized)) { 47 const num = Number.parseInt(normalized, 10); 48 49 if (num < 1) { 50 throw new Error('Issue number must be greater than 0'); 51 } 52 53 // Query all issues for this repo 54 const { issues } = await listIssues({ 55 client, 56 repoAtUri, 57 limit: 100, // Adjust if needed for large repos 58 }); 59 60 // Sort by creation time (oldest first) 61 const sorted = issues.sort( 62 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 63 ); 64 65 // Get issue at index (1-based numbering) 66 const issue = sorted[num - 1]; 67 if (!issue) { 68 throw new Error(`Issue #${num} not found`); 69 } 70 71 return { 72 uri: issue.uri, 73 displayId: `#${num}`, 74 }; 75 } 76 77 // Treat as rkey - validate and build URI 78 if (!/^[a-zA-Z0-9._-]+$/.test(normalized)) { 79 throw new Error(`Invalid issue identifier: ${input}`); 80 } 81 82 const session = await requireAuth(client); 83 return { 84 uri: `at://${session.did}/sh.tangled.repo.issue/${normalized}`, 85 displayId: normalized, 86 }; 87} 88 89/** 90 * Issue view subcommand 91 */ 92function createViewCommand(): Command { 93 return new Command('view') 94 .description('View details of a specific issue') 95 .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey') 96 .option( 97 '--json [fields]', 98 'Output JSON; optionally specify comma-separated fields (title, body, state, author, createdAt, uri, cid)' 99 ) 100 .action(async (issueId: string, options: { json?: string | true }) => { 101 try { 102 // 1. Validate auth 103 const client = createApiClient(); 104 if (!(await client.resumeSession())) { 105 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 106 process.exit(1); 107 } 108 109 // 2. Get repo context 110 const context = await getCurrentRepoContext(); 111 if (!context) { 112 console.error('✗ Not in a Tangled repository'); 113 console.error('\nTo use this repository with Tangled, add a remote:'); 114 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 115 process.exit(1); 116 } 117 118 // 3. Build repo AT-URI 119 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 120 121 // 4. Resolve issue ID to URI 122 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 123 124 // 5. Fetch issue details 125 const issue = await getIssue({ client, issueUri }); 126 127 // 6. Fetch issue state 128 const state = await getIssueState({ client, issueUri: issue.uri }); 129 130 // 7. Output result 131 if (options.json !== undefined) { 132 const issueData = { 133 title: issue.title, 134 body: issue.body, 135 state, 136 author: issue.author, 137 createdAt: issue.createdAt, 138 uri: issue.uri, 139 cid: issue.cid, 140 }; 141 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 142 return; 143 } 144 145 console.log(`\nIssue ${displayId} ${formatIssueState(state)}`); 146 console.log(`Title: ${issue.title}`); 147 console.log(`Author: ${issue.author}`); 148 console.log(`Created: ${formatDate(issue.createdAt)}`); 149 console.log(`Repo: ${context.name}`); 150 console.log(`URI: ${issue.uri}`); 151 152 if (issue.body) { 153 console.log('\nBody:'); 154 console.log(issue.body); 155 } 156 157 console.log(); // Empty line at end 158 } catch (error) { 159 console.error( 160 `✗ Failed to view issue: ${error instanceof Error ? error.message : 'Unknown error'}` 161 ); 162 process.exit(1); 163 } 164 }); 165} 166 167/** 168 * Issue edit subcommand 169 */ 170function createEditCommand(): Command { 171 return new Command('edit') 172 .description('Edit an issue title and/or body') 173 .argument('<issue-id>', 'Issue number or rkey') 174 .option('-t, --title <string>', 'New issue title') 175 .option('-b, --body <string>', 'New issue body text') 176 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 177 .option( 178 '--json [fields]', 179 'Output JSON of the updated issue; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 180 ) 181 .action( 182 async ( 183 issueId: string, 184 options: { title?: string; body?: string; bodyFile?: string; json?: string | true } 185 ) => { 186 try { 187 // 1. Validate at least one option provided 188 if (!options.title && !options.body && !options.bodyFile) { 189 console.error('✗ At least one of --title, --body, or --body-file must be provided'); 190 process.exit(1); 191 } 192 193 // 2. Validate auth 194 const client = createApiClient(); 195 if (!(await client.resumeSession())) { 196 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 197 process.exit(1); 198 } 199 200 // 3. Get repo context 201 const context = await getCurrentRepoContext(); 202 if (!context) { 203 console.error('✗ Not in a Tangled repository'); 204 console.error('\nTo use this repository with Tangled, add a remote:'); 205 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 206 process.exit(1); 207 } 208 209 // 4. Build repo AT-URI 210 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 211 212 // 5. Resolve issue ID to URI 213 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 214 215 // 6. Handle body input 216 const body = await readBodyInput(options.body, options.bodyFile); 217 218 // 7. Validate inputs 219 const validTitle = options.title ? validateIssueTitle(options.title) : undefined; 220 const validBody = body !== undefined ? validateIssueBody(body) : undefined; 221 222 // 8. Update issue 223 const updatedIssue = await updateIssue({ 224 client, 225 issueUri, 226 title: validTitle, 227 body: validBody, 228 }); 229 230 // 9. Output result 231 if (options.json !== undefined) { 232 const issueData = { 233 title: updatedIssue.title, 234 body: updatedIssue.body, 235 author: updatedIssue.author, 236 createdAt: updatedIssue.createdAt, 237 uri: updatedIssue.uri, 238 cid: updatedIssue.cid, 239 }; 240 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 241 return; 242 } 243 244 const updated: string[] = []; 245 if (validTitle !== undefined) updated.push('title'); 246 if (validBody !== undefined) updated.push('body'); 247 248 console.log(`✓ Issue ${displayId} updated`); 249 console.log(` Updated: ${updated.join(', ')}`); 250 } catch (error) { 251 console.error( 252 `✗ Failed to edit issue: ${error instanceof Error ? error.message : 'Unknown error'}` 253 ); 254 process.exit(1); 255 } 256 } 257 ); 258} 259 260/** 261 * Issue close subcommand 262 */ 263function createCloseCommand(): Command { 264 return new Command('close') 265 .description('Close an issue') 266 .argument('<issue-id>', 'Issue number or rkey') 267 .action(async (issueId: string) => { 268 try { 269 // 1. Validate auth 270 const client = createApiClient(); 271 if (!(await client.resumeSession())) { 272 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 273 process.exit(1); 274 } 275 276 // 2. Get repo context 277 const context = await getCurrentRepoContext(); 278 if (!context) { 279 console.error('✗ Not in a Tangled repository'); 280 console.error('\nTo use this repository with Tangled, add a remote:'); 281 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 282 process.exit(1); 283 } 284 285 // 3. Build repo AT-URI 286 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 287 288 // 4. Resolve issue ID to URI 289 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 290 291 // 5. Close issue 292 await closeIssue({ client, issueUri }); 293 294 // 6. Display success 295 console.log(`✓ Issue ${displayId} closed`); 296 } catch (error) { 297 console.error( 298 `✗ Failed to close issue: ${error instanceof Error ? error.message : 'Unknown error'}` 299 ); 300 process.exit(1); 301 } 302 }); 303} 304 305/** 306 * Issue reopen subcommand 307 */ 308function createReopenCommand(): Command { 309 return new Command('reopen') 310 .description('Reopen a closed issue') 311 .argument('<issue-id>', 'Issue number or rkey') 312 .action(async (issueId: string) => { 313 try { 314 // 1. Validate auth 315 const client = createApiClient(); 316 if (!(await client.resumeSession())) { 317 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 318 process.exit(1); 319 } 320 321 // 2. Get repo context 322 const context = await getCurrentRepoContext(); 323 if (!context) { 324 console.error('✗ Not in a Tangled repository'); 325 console.error('\nTo use this repository with Tangled, add a remote:'); 326 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 327 process.exit(1); 328 } 329 330 // 3. Build repo AT-URI 331 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 332 333 // 4. Resolve issue ID to URI 334 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 335 336 // 5. Reopen issue 337 await reopenIssue({ client, issueUri }); 338 339 // 6. Display success 340 console.log(`✓ Issue ${displayId} reopened`); 341 } catch (error) { 342 console.error( 343 `✗ Failed to reopen issue: ${error instanceof Error ? error.message : 'Unknown error'}` 344 ); 345 process.exit(1); 346 } 347 }); 348} 349 350/** 351 * Issue delete subcommand 352 */ 353function createDeleteCommand(): Command { 354 return new Command('delete') 355 .description('Delete an issue permanently') 356 .argument('<issue-id>', 'Issue number or rkey') 357 .option('-f, --force', 'Skip confirmation prompt') 358 .action(async (issueId: string, options: { force?: boolean }) => { 359 // 1. Validate auth 360 const client = createApiClient(); 361 if (!(await client.resumeSession())) { 362 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 363 process.exit(1); 364 } 365 366 // 2. Get repo context 367 const context = await getCurrentRepoContext(); 368 if (!context) { 369 console.error('✗ Not in a Tangled repository'); 370 console.error('\nTo use this repository with Tangled, add a remote:'); 371 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 372 process.exit(1); 373 } 374 375 // 3. Build repo AT-URI and resolve issue ID 376 let issueUri: string; 377 let displayId: string; 378 try { 379 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 380 ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri)); 381 } catch (error) { 382 console.error( 383 `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` 384 ); 385 process.exit(1); 386 } 387 388 // 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly) 389 if (!options.force) { 390 const confirmed = await confirm({ 391 message: `Are you sure you want to delete issue ${displayId}? This cannot be undone.`, 392 default: false, 393 }); 394 395 if (!confirmed) { 396 console.log('Deletion cancelled.'); 397 process.exit(0); 398 } 399 } 400 401 // 5. Delete issue 402 try { 403 await deleteIssue({ client, issueUri }); 404 console.log(`✓ Issue ${displayId} deleted`); 405 } catch (error) { 406 console.error( 407 `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` 408 ); 409 process.exit(1); 410 } 411 }); 412} 413 414/** 415 * Create the issue command with all subcommands 416 */ 417export function createIssueCommand(): Command { 418 const issue = new Command('issue'); 419 issue.description('Manage issues in Tangled repositories'); 420 421 issue.addCommand(createCreateCommand()); 422 issue.addCommand(createListCommand()); 423 issue.addCommand(createViewCommand()); 424 issue.addCommand(createEditCommand()); 425 issue.addCommand(createCloseCommand()); 426 issue.addCommand(createReopenCommand()); 427 issue.addCommand(createDeleteCommand()); 428 429 return issue; 430} 431 432/** 433 * Issue create subcommand 434 */ 435function createCreateCommand(): Command { 436 return new Command('create') 437 .description('Create a new issue') 438 .argument('<title>', 'Issue title') 439 .option('-b, --body <string>', 'Issue body text') 440 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 441 .option( 442 '--json [fields]', 443 'Output JSON; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 444 ) 445 .action( 446 async ( 447 title: string, 448 options: { body?: string; bodyFile?: string; json?: string | true } 449 ) => { 450 try { 451 // 1. Validate auth 452 const client = createApiClient(); 453 if (!(await client.resumeSession())) { 454 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 455 process.exit(1); 456 } 457 458 // 2. Get repo context 459 const context = await getCurrentRepoContext(); 460 if (!context) { 461 console.error('✗ Not in a Tangled repository'); 462 console.error('\nTo use this repository with Tangled, add a remote:'); 463 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 464 process.exit(1); 465 } 466 467 // 3. Validate title 468 const validTitle = validateIssueTitle(title); 469 470 // 4. Handle body input 471 const body = await readBodyInput(options.body, options.bodyFile); 472 if (body !== undefined) { 473 validateIssueBody(body); 474 } 475 476 // 5. Build repo AT-URI 477 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 478 479 // 6. Create issue (suppress progress message in JSON mode) 480 if (options.json === undefined) { 481 console.log('Creating issue...'); 482 } 483 const issue = await createIssue({ 484 client, 485 repoAtUri, 486 title: validTitle, 487 body, 488 }); 489 490 // 7. Output result 491 if (options.json !== undefined) { 492 const issueData = { 493 title: issue.title, 494 body: issue.body, 495 author: issue.author, 496 createdAt: issue.createdAt, 497 uri: issue.uri, 498 cid: issue.cid, 499 }; 500 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 501 return; 502 } 503 504 const rkey = extractRkey(issue.uri); 505 console.log(`\n✓ Issue created: #${rkey}`); 506 console.log(` Title: ${issue.title}`); 507 console.log(` URI: ${issue.uri}`); 508 } catch (error) { 509 console.error( 510 `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}` 511 ); 512 process.exit(1); 513 } 514 } 515 ); 516} 517 518/** 519 * Issue list subcommand 520 */ 521function createListCommand(): Command { 522 return new Command('list') 523 .description('List issues for the current repository') 524 .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50') 525 .option( 526 '--json [fields]', 527 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' 528 ) 529 .action(async (options: { limit: string; json?: string | true }) => { 530 try { 531 // 1. Validate auth 532 const client = createApiClient(); 533 if (!(await client.resumeSession())) { 534 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 535 process.exit(1); 536 } 537 538 // 2. Get repo context 539 const context = await getCurrentRepoContext(); 540 if (!context) { 541 console.error('✗ Not in a Tangled repository'); 542 console.error('\nTo use this repository with Tangled, add a remote:'); 543 console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 544 process.exit(1); 545 } 546 547 // 3. Build repo AT-URI 548 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 549 550 // 4. Fetch issues 551 const limit = Number.parseInt(options.limit, 10); 552 if (Number.isNaN(limit) || limit < 1 || limit > 100) { 553 console.error('✗ Invalid limit. Must be between 1 and 100.'); 554 process.exit(1); 555 } 556 557 const { issues } = await listIssues({ 558 client, 559 repoAtUri, 560 limit, 561 }); 562 563 // 5. Handle empty results 564 if (issues.length === 0) { 565 if (options.json !== undefined) { 566 console.log('[]'); 567 } else { 568 console.log('No issues found for this repository.'); 569 } 570 return; 571 } 572 573 // Sort issues by creation time (oldest first) for consistent numbering 574 const sortedIssues = issues.sort( 575 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 576 ); 577 578 // Build issue data with states (in parallel for performance) 579 const issueData = await Promise.all( 580 sortedIssues.map(async (issue, i) => { 581 const state = await getIssueState({ client, issueUri: issue.uri }); 582 return { 583 number: i + 1, 584 title: issue.title, 585 body: issue.body, 586 state, 587 author: issue.author, 588 createdAt: issue.createdAt, 589 uri: issue.uri, 590 cid: issue.cid, 591 }; 592 }) 593 ); 594 595 // 6. Output results 596 if (options.json !== undefined) { 597 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 598 return; 599 } 600 601 console.log(`\nFound ${issueData.length} issue${issueData.length === 1 ? '' : 's'}:\n`); 602 603 for (const item of issueData) { 604 const stateBadge = formatIssueState(item.state); 605 const date = formatDate(item.createdAt); 606 console.log(` #${item.number} ${stateBadge} ${item.title}`); 607 console.log(` Created ${date}`); 608 console.log(); 609 } 610 } catch (error) { 611 console.error( 612 `✗ Failed to list issues: ${error instanceof Error ? error.message : 'Unknown error'}` 613 ); 614 process.exit(1); 615 } 616 }); 617}