Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/just-cameron/tangled-cli. Automation-first CLI for Tangled.org
Something went wrong. Try again.
21 kB · 617 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618import { confirm } from '@inquirer/prompts';import { Command } from 'commander';import { createApiClient } from '../lib/api-client.js';import type { TangledApiClient } from '../lib/api-client.js';import { getCurrentRepoContext } from '../lib/context.js';import { closeIssue, createIssue, deleteIssue, getIssue, getIssueState, listIssues, reopenIssue, updateIssue,} from '../lib/issues-api.js';import { buildRepoAtUri } from '../utils/at-uri.js';import { requireAuth } from '../utils/auth-helpers.js';import { readBodyInput } from '../utils/body-input.js';import { formatDate, formatIssueState, outputJson } from '../utils/formatting.js';import { validateIssueBody, validateIssueTitle } from '../utils/validation.js';
/** * Extract rkey from AT-URI */function extractRkey(uri: string): string { const parts = uri.split('/'); return parts[parts.length - 1] || 'unknown';}
/** * Resolve issue number or rkey to full AT-URI * @param input - User input: number ("1"), hash ("#1"), or rkey ("3mef...") * @param client - API client * @param repoAtUri - Repository AT-URI * @returns Object with full issue AT-URI and display identifier */async function resolveIssueUri( input: string, client: TangledApiClient, repoAtUri: string): Promise<{ uri: string; displayId: string }> { // Strip # prefix if present const normalized = input.startsWith('#') ? input.slice(1) : input;
// Check if numeric if (/^\d+$/.test(normalized)) { const num = Number.parseInt(normalized, 10);
if (num < 1) { throw new Error('Issue number must be greater than 0'); }
// Query all issues for this repo const { issues } = await listIssues({ client, repoAtUri, limit: 100, // Adjust if needed for large repos });
// Sort by creation time (oldest first) const sorted = issues.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() );
// Get issue at index (1-based numbering) const issue = sorted[num - 1]; if (!issue) { throw new Error(`Issue #${num} not found`); }
return { uri: issue.uri, displayId: `#${num}`, }; }
// Treat as rkey - validate and build URI if (!/^[a-zA-Z0-9._-]+$/.test(normalized)) { throw new Error(`Invalid issue identifier: ${input}`); }
const session = await requireAuth(client); return { uri: `at://${session.did}/sh.tangled.repo.issue/${normalized}`, displayId: normalized, };}
/** * Issue view subcommand */function createViewCommand(): Command { return new Command('view') .description('View details of a specific issue') .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey') .option( '--json [fields]', 'Output JSON; optionally specify comma-separated fields (title, body, state, author, createdAt, uri, cid)' ) .action(async (issueId: string, options: { json?: string | true }) => { try { // 1. Validate auth const client = createApiClient(); if (!(await client.resumeSession())) { console.error('✗ Not authenticated. Run "tangled auth login" first.'); process.exit(1); }
// 2. Get repo context const context = await getCurrentRepoContext(); if (!context) { console.error('✗ Not in a Tangled repository'); console.error('\nTo use this repository with Tangled, add a remote:'); console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); process.exit(1); }
// 3. Build repo AT-URI const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
// 4. Resolve issue ID to URI const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
// 5. Fetch issue details const issue = await getIssue({ client, issueUri });
// 6. Fetch issue state const state = await getIssueState({ client, issueUri: issue.uri });
// 7. Output result if (options.json !== undefined) { const issueData = { title: issue.title, body: issue.body, state, author: issue.author, createdAt: issue.createdAt, uri: issue.uri, cid: issue.cid, }; outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); return; }
console.log(`\nIssue ${displayId} ${formatIssueState(state)}`); console.log(`Title: ${issue.title}`); console.log(`Author: ${issue.author}`); console.log(`Created: ${formatDate(issue.createdAt)}`); console.log(`Repo: ${context.name}`); console.log(`URI: ${issue.uri}`);
if (issue.body) { console.log('\nBody:'); console.log(issue.body); }
console.log(); // Empty line at end } catch (error) { console.error( `✗ Failed to view issue: ${error instanceof Error ? error.message : 'Unknown error'}` ); process.exit(1); } });}
/** * Issue edit subcommand */function createEditCommand(): Command { return new Command('edit') .description('Edit an issue title and/or body') .argument('<issue-id>', 'Issue number or rkey') .option('-t, --title <string>', 'New issue title') .option('-b, --body <string>', 'New issue body text') .option('-F, --body-file <path>', 'Read body from file (- for stdin)') .option( '--json [fields]', 'Output JSON of the updated issue; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' ) .action( async ( issueId: string, options: { title?: string; body?: string; bodyFile?: string; json?: string | true } ) => { try { // 1. Validate at least one option provided if (!options.title && !options.body && !options.bodyFile) { console.error('✗ At least one of --title, --body, or --body-file must be provided'); process.exit(1); }
// 2. Validate auth const client = createApiClient(); if (!(await client.resumeSession())) { console.error('✗ Not authenticated. Run "tangled auth login" first.'); process.exit(1); }
// 3. Get repo context const context = await getCurrentRepoContext(); if (!context) { console.error('✗ Not in a Tangled repository'); console.error('\nTo use this repository with Tangled, add a remote:'); console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); process.exit(1); }
// 4. Build repo AT-URI const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
// 5. Resolve issue ID to URI const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
// 6. Handle body input const body = await readBodyInput(options.body, options.bodyFile);
// 7. Validate inputs const validTitle = options.title ? validateIssueTitle(options.title) : undefined; const validBody = body !== undefined ? validateIssueBody(body) : undefined;
// 8. Update issue const updatedIssue = await updateIssue({ client, issueUri, title: validTitle, body: validBody, });
// 9. Output result if (options.json !== undefined) { const issueData = { title: updatedIssue.title, body: updatedIssue.body, author: updatedIssue.author, createdAt: updatedIssue.createdAt, uri: updatedIssue.uri, cid: updatedIssue.cid, }; outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); return; }
const updated: string[] = []; if (validTitle !== undefined) updated.push('title'); if (validBody !== undefined) updated.push('body');
console.log(`✓ Issue ${displayId} updated`); console.log(` Updated: ${updated.join(', ')}`); } catch (error) { console.error( `✗ Failed to edit issue: ${error instanceof Error ? error.message : 'Unknown error'}` ); process.exit(1); } } );}
/** * Issue close subcommand */function createCloseCommand(): Command { return new Command('close') .description('Close an issue') .argument('<issue-id>', 'Issue number or rkey') .action(async (issueId: string) => { try { // 1. Validate auth const client = createApiClient(); if (!(await client.resumeSession())) { console.error('✗ Not authenticated. Run "tangled auth login" first.'); process.exit(1); }
// 2. Get repo context const context = await getCurrentRepoContext(); if (!context) { console.error('✗ Not in a Tangled repository'); console.error('\nTo use this repository with Tangled, add a remote:'); console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); process.exit(1); }
// 3. Build repo AT-URI const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
// 4. Resolve issue ID to URI const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
// 5. Close issue await closeIssue({ client, issueUri });
// 6. Display success console.log(`✓ Issue ${displayId} closed`); } catch (error) { console.error( `✗ Failed to close issue: ${error instanceof Error ? error.message : 'Unknown error'}` ); process.exit(1); } });}
/** * Issue reopen subcommand */function createReopenCommand(): Command { return new Command('reopen') .description('Reopen a closed issue') .argument('<issue-id>', 'Issue number or rkey') .action(async (issueId: string) => { try { // 1. Validate auth const client = createApiClient(); if (!(await client.resumeSession())) { console.error('✗ Not authenticated. Run "tangled auth login" first.'); process.exit(1); }
// 2. Get repo context const context = await getCurrentRepoContext(); if (!context) { console.error('✗ Not in a Tangled repository'); console.error('\nTo use this repository with Tangled, add a remote:'); console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); process.exit(1); }
// 3. Build repo AT-URI const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
// 4. Resolve issue ID to URI const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri);
// 5. Reopen issue await reopenIssue({ client, issueUri });
// 6. Display success console.log(`✓ Issue ${displayId} reopened`); } catch (error) { console.error( `✗ Failed to reopen issue: ${error instanceof Error ? error.message : 'Unknown error'}` ); process.exit(1); } });}
/** * Issue delete subcommand */function createDeleteCommand(): Command { return new Command('delete') .description('Delete an issue permanently') .argument('<issue-id>', 'Issue number or rkey') .option('-f, --force', 'Skip confirmation prompt') .action(async (issueId: string, options: { force?: boolean }) => { // 1. Validate auth const client = createApiClient(); if (!(await client.resumeSession())) { console.error('✗ Not authenticated. Run "tangled auth login" first.'); process.exit(1); }
// 2. Get repo context const context = await getCurrentRepoContext(); if (!context) { console.error('✗ Not in a Tangled repository'); console.error('\nTo use this repository with Tangled, add a remote:'); console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); process.exit(1); }
// 3. Build repo AT-URI and resolve issue ID let issueUri: string; let displayId: string; try { const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri)); } catch (error) { console.error( `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ); process.exit(1); }
// 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly) if (!options.force) { const confirmed = await confirm({ message: `Are you sure you want to delete issue ${displayId}? This cannot be undone.`, default: false, });
if (!confirmed) { console.log('Deletion cancelled.'); process.exit(0); } }
// 5. Delete issue try { await deleteIssue({ client, issueUri }); console.log(`✓ Issue ${displayId} deleted`); } catch (error) { console.error( `✗ Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ); process.exit(1); } });}
/** * Create the issue command with all subcommands */export function createIssueCommand(): Command { const issue = new Command('issue'); issue.description('Manage issues in Tangled repositories');
issue.addCommand(createCreateCommand()); issue.addCommand(createListCommand()); issue.addCommand(createViewCommand()); issue.addCommand(createEditCommand()); issue.addCommand(createCloseCommand()); issue.addCommand(createReopenCommand()); issue.addCommand(createDeleteCommand());
return issue;}
/** * Issue create subcommand */function createCreateCommand(): Command { return new Command('create') .description('Create a new issue') .argument('<title>', 'Issue title') .option('-b, --body <string>', 'Issue body text') .option('-F, --body-file <path>', 'Read body from file (- for stdin)') .option( '--json [fields]', 'Output JSON; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' ) .action( async ( title: string, options: { body?: string; bodyFile?: string; json?: string | true } ) => { try { // 1. Validate auth const client = createApiClient(); if (!(await client.resumeSession())) { console.error('✗ Not authenticated. Run "tangled auth login" first.'); process.exit(1); }
// 2. Get repo context const context = await getCurrentRepoContext(); if (!context) { console.error('✗ Not in a Tangled repository'); console.error('\nTo use this repository with Tangled, add a remote:'); console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); process.exit(1); }
// 3. Validate title const validTitle = validateIssueTitle(title);
// 4. Handle body input const body = await readBodyInput(options.body, options.bodyFile); if (body !== undefined) { validateIssueBody(body); }
// 5. Build repo AT-URI const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
// 6. Create issue (suppress progress message in JSON mode) if (options.json === undefined) { console.log('Creating issue...'); } const issue = await createIssue({ client, repoAtUri, title: validTitle, body, });
// 7. Output result if (options.json !== undefined) { const issueData = { title: issue.title, body: issue.body, author: issue.author, createdAt: issue.createdAt, uri: issue.uri, cid: issue.cid, }; outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); return; }
const rkey = extractRkey(issue.uri); console.log(`\n✓ Issue created: #${rkey}`); console.log(` Title: ${issue.title}`); console.log(` URI: ${issue.uri}`); } catch (error) { console.error( `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}` ); process.exit(1); } } );}
/** * Issue list subcommand */function createListCommand(): Command { return new Command('list') .description('List issues for the current repository') .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50') .option( '--json [fields]', 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' ) .action(async (options: { limit: string; json?: string | true }) => { try { // 1. Validate auth const client = createApiClient(); if (!(await client.resumeSession())) { console.error('✗ Not authenticated. Run "tangled auth login" first.'); process.exit(1); }
// 2. Get repo context const context = await getCurrentRepoContext(); if (!context) { console.error('✗ Not in a Tangled repository'); console.error('\nTo use this repository with Tangled, add a remote:'); console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); process.exit(1); }
// 3. Build repo AT-URI const repoAtUri = await buildRepoAtUri(context.owner, context.name, client);
// 4. Fetch issues const limit = Number.parseInt(options.limit, 10); if (Number.isNaN(limit) || limit < 1 || limit > 100) { console.error('✗ Invalid limit. Must be between 1 and 100.'); process.exit(1); }
const { issues } = await listIssues({ client, repoAtUri, limit, });
// 5. Handle empty results if (issues.length === 0) { if (options.json !== undefined) { console.log('[]'); } else { console.log('No issues found for this repository.'); } return; }
// Sort issues by creation time (oldest first) for consistent numbering const sortedIssues = issues.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() );
// Build issue data with states (in parallel for performance) const issueData = await Promise.all( sortedIssues.map(async (issue, i) => { const state = await getIssueState({ client, issueUri: issue.uri }); return { number: i + 1, title: issue.title, body: issue.body, state, author: issue.author, createdAt: issue.createdAt, uri: issue.uri, cid: issue.cid, }; }) );
// 6. Output results if (options.json !== undefined) { outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); return; }
console.log(`\nFound ${issueData.length} issue${issueData.length === 1 ? '' : 's'}:\n`);
for (const item of issueData) { const stateBadge = formatIssueState(item.state); const date = formatDate(item.createdAt); console.log(` #${item.number} ${stateBadge} ${item.title}`); console.log(` Created ${date}`); console.log(); } } catch (error) { console.error( `✗ Failed to list issues: ${error instanceof Error ? error.message : 'Unknown error'}` ); process.exit(1); } });}