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.
13 kB · 476 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477import { parseAtUri } from '../utils/at-uri.js';import { requireAuth } from '../utils/auth-helpers.js';import type { TangledApiClient } from './api-client.js';import { getBacklinks } from './constellation.js';import { getRecordFromDid } from './public-records.js';
/** * Issue record type based on sh.tangled.repo.issue lexicon * @see lexicons/sh/tangled/issue/issue.json */export interface IssueRecord { $type: 'sh.tangled.repo.issue'; repo: string; title: string; body?: string; createdAt: string; mentions?: string[]; references?: string[]; [key: string]: unknown;}
/** * Issue record with metadata */export interface IssueWithMetadata extends IssueRecord { uri: string; // AT-URI of the issue cid: string; // Content ID author: string; // Creator's DID}
/** * Parameters for creating an issue */export interface CreateIssueParams { client: TangledApiClient; repoDid: string; title: string; body?: string;}
/** * Parameters for listing issues */export interface ListIssuesParams { client: TangledApiClient; repoDid: string; /** Optional aliases (AT-URIs or other DIDs) that older/buggy issues may store in `.repo` */ repoAliases?: string[]; limit?: number; cursor?: string;}
/** * Parameters for getting a specific issue */export interface GetIssueParams { client: TangledApiClient; issueUri: string;}
/** * Parameters for updating an issue */export interface UpdateIssueParams { client: TangledApiClient; issueUri: string; title?: string; body?: string;}
/** * Parameters for closing an issue */export interface CloseIssueParams { client: TangledApiClient; issueUri: string;}
/** * Parameters for getting issue state */export interface GetIssueStateParams { client: TangledApiClient; issueUri: string;}
/** * Parameters for reopening an issue */export interface ReopenIssueParams { client: TangledApiClient; issueUri: string;}
/** * Parse and validate an issue AT-URI * @throws Error if URI is invalid or missing rkey * @returns Parsed URI components */function parseIssueUri(issueUri: string): { did: string; collection: string; rkey: string;} { const parsed = parseAtUri(issueUri); if (!parsed || !parsed.rkey) { throw new Error(`Invalid issue AT-URI: ${issueUri}`); }
return { did: parsed.did, collection: parsed.collection, rkey: parsed.rkey, };}
/** * Create a new issue */export async function createIssue(params: CreateIssueParams): Promise<IssueWithMetadata> { const { client, repoDid, title, body } = params;
// Validate authentication const session = await requireAuth(client);
// Build issue record const record: IssueRecord = { $type: 'sh.tangled.repo.issue', repo: repoDid, // bare repo DID (lexicon format "did"), not an AT-URI title, body, createdAt: new Date().toISOString(), };
try { // Create record via AT Protocol const response = await client.getAgent().com.atproto.repo.createRecord({ repo: session.did, collection: 'sh.tangled.repo.issue', record, });
return { ...record, uri: response.data.uri, cid: response.data.cid, author: session.did, }; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to create issue: ${error.message}`); } throw new Error('Failed to create issue: Unknown error'); }}
/** * List issues for a repository */export async function listIssues(params: ListIssuesParams): Promise<{ issues: IssueWithMetadata[]; cursor?: string;}> { const { client, repoDid, limit = 50, cursor } = params;
// Validate authentication await requireAuth(client);
try { // Query constellation for all issues that reference this repo across all PDSs const backlinks = await getBacklinks(repoDid, 'sh.tangled.repo.issue', '.repo', limit, cursor);
// Fetch each issue record individually (constellation only gives us the AT-URI components) const issuePromises = backlinks.records.map(async ({ did, collection, rkey }) => { const response = await getRecordFromDid(client, did, collection, rkey); return { ...(response.data.value as IssueRecord), uri: response.data.uri, cid: response.data.cid as string, author: did, }; });
const issues = await Promise.all(issuePromises);
return { issues, cursor: backlinks.cursor ?? undefined, }; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to list issues: ${error.message}`); } throw new Error('Failed to list issues: Unknown error'); }}
/** * Get a specific issue */export async function getIssue(params: GetIssueParams): Promise<IssueWithMetadata> { const { client, issueUri } = params;
// Validate authentication await requireAuth(client);
// Parse issue URI const { did, collection, rkey } = parseIssueUri(issueUri);
try { // Get record via AT Protocol const response = await getRecordFromDid(client, did, collection, rkey);
const record = response.data.value as IssueRecord;
return { ...record, uri: response.data.uri, cid: response.data.cid as string, // CID is always present in AT Protocol responses author: did, }; } catch (error) { if (error instanceof Error) { if (error.message.includes('not found')) { throw new Error(`Issue not found: ${issueUri}`); } throw new Error(`Failed to get issue: ${error.message}`); } throw new Error('Failed to get issue: Unknown error'); }}
/** * Update an issue (title and/or body) */export async function updateIssue(params: UpdateIssueParams): Promise<IssueWithMetadata> { const { client, issueUri, title, body } = params;
// Validate authentication const session = await requireAuth(client);
// Parse issue URI const { did, collection, rkey } = parseIssueUri(issueUri);
// Verify user owns the issue if (did !== session.did) { throw new Error('Cannot update issue: you are not the author'); }
try { // Get current issue to merge with updates const currentIssue = await getIssue({ client, issueUri });
// Build updated record (merge existing with new values) const { uri: _uri, cid: _cid, author: _author, ...currentRecord } = currentIssue; const updatedRecord: IssueRecord = { ...currentRecord, ...(title !== undefined && { title }), ...(body !== undefined && { body }), };
// Update record with CID swap for atomic update const response = await client.getAgent().com.atproto.repo.putRecord({ repo: did, collection, rkey, record: updatedRecord, swapRecord: currentIssue.cid, });
return { ...updatedRecord, uri: issueUri, cid: response.data.cid, author: did, }; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to update issue: ${error.message}`); } throw new Error('Failed to update issue: Unknown error'); }}
/** * Close an issue by creating/updating a state record */export async function closeIssue(params: CloseIssueParams): Promise<void> { const { client, issueUri } = params;
// Validate authentication const session = await requireAuth(client);
try { // Verify issue exists await getIssue({ client, issueUri });
// Create state record const stateRecord = { $type: 'sh.tangled.repo.issue.state', issue: issueUri, state: 'sh.tangled.repo.issue.state.closed', };
// Create state record await client.getAgent().com.atproto.repo.createRecord({ repo: session.did, collection: 'sh.tangled.repo.issue.state', record: stateRecord, }); } catch (error) { if (error instanceof Error) { throw new Error(`Failed to close issue: ${error.message}`); } throw new Error('Failed to close issue: Unknown error'); }}
/** * Get the state of an issue (open or closed) * @returns 'open' or 'closed' (defaults to 'open' if no state record exists) */export async function getIssueState(params: GetIssueStateParams): Promise<'open' | 'closed'> { const { client, issueUri } = params;
// Validate authentication await requireAuth(client);
try { // Query constellation for all state records that reference this issue across all PDSs const backlinks = await getBacklinks(issueUri, 'sh.tangled.repo.issue.state', '.issue', 100);
if (backlinks.records.length === 0) { return 'open'; }
// Fetch each state record in parallel const statePromises = backlinks.records.map(async ({ did, collection, rkey }) => { const response = await getRecordFromDid(client, did, collection, rkey); return { rkey, value: response.data.value as { state?: 'sh.tangled.repo.issue.state.open' | 'sh.tangled.repo.issue.state.closed'; }, }; });
const stateRecords = await Promise.all(statePromises);
// Sort by rkey ascending — TID rkeys are time-ordered, so the last is most recent stateRecords.sort((a, b) => a.rkey.localeCompare(b.rkey)); const latestState = stateRecords[stateRecords.length - 1];
if (latestState.value.state === 'sh.tangled.repo.issue.state.closed') { return 'closed'; }
return 'open'; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to get issue state: ${error.message}`); } throw new Error('Failed to get issue state: Unknown error'); }}
/** * Resolve a sequential issue number from a displayId or by scanning the issue list. * Fast path: if displayId is "#N", return N directly. * Fallback: fetch all issues, sort oldest-first, return 1-based position. */export async function resolveSequentialNumber( displayId: string, issueUri: string, client: TangledApiClient, repoDid: string): Promise<number | undefined> { const match = displayId.match(/^#(\d+)$/); if (match) return Number.parseInt(match[1], 10);
const byUri = new Map<string, IssueWithMetadata>(); let cursor: string | undefined; do { const page = await listIssues({ client, repoDid, limit: 100, cursor }); for (const issue of page.issues) byUri.set(issue.uri, issue); cursor = page.cursor; } while (cursor); const sorted = [...byUri.values()].sort((a, b) => { const byTime = a.createdAt.localeCompare(b.createdAt); return byTime !== 0 ? byTime : a.uri.localeCompare(b.uri); }); const idx = sorted.findIndex((i) => i.uri === issueUri); return idx >= 0 ? idx + 1 : undefined;}
/** * Canonical JSON shape for a single issue, used by all issue commands. */export interface IssueData { id?: string; number: number | undefined; numberKind?: 'computed' | 'unknown'; title: string; body?: string; state: 'open' | 'closed'; author: string; createdAt: string; uri: string; cid: string;}
/** * Fetch a complete IssueData object ready for JSON output. * Fetches the issue record and sequential number in parallel. * If stateOverride is supplied (e.g. 'closed' after a close operation), * getIssueState is skipped; otherwise the current state is fetched. */export async function getCompleteIssueData( client: TangledApiClient, issueUri: string, displayId: string, repoDid: string, stateOverride?: 'open' | 'closed'): Promise<IssueData> { const [issue, number] = await Promise.all([ getIssue({ client, issueUri }), resolveSequentialNumber(displayId, issueUri, client, repoDid), ]); const state = stateOverride ?? (await getIssueState({ client, issueUri })); return { id: issue.uri.split('/').pop() ?? issue.uri, number, numberKind: number === undefined ? 'unknown' : 'computed', title: issue.title, body: issue.body, state, author: issue.author, createdAt: issue.createdAt, uri: issue.uri, cid: issue.cid, };}
/** * Reopen a closed issue by creating an open state record */export async function reopenIssue(params: ReopenIssueParams): Promise<void> { const { client, issueUri } = params;
// Validate authentication const session = await requireAuth(client);
try { // Verify issue exists await getIssue({ client, issueUri });
// Create state record with open state const stateRecord = { $type: 'sh.tangled.repo.issue.state', issue: issueUri, state: 'sh.tangled.repo.issue.state.open', };
// Create state record await client.getAgent().com.atproto.repo.createRecord({ repo: session.did, collection: 'sh.tangled.repo.issue.state', record: stateRecord, }); } catch (error) { if (error instanceof Error) { throw new Error(`Failed to reopen issue: ${error.message}`); } throw new Error('Failed to reopen issue: Unknown error'); }}