Something went wrong. Try again.
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 fro
Something went wrong. Try again.
12 kB · 473 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474import { 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';
/** * 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; repoId: string; title: string; body?: string;}
/** * Parameters for listing issues */export interface ListIssuesParams { client: TangledApiClient; repoId: 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, repoId, title, body } = params;
// Validate authentication const session = await requireAuth(client);
// Build issue record const record: IssueRecord = { $type: 'sh.tangled.repo.issue', repo: repoId, 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, repoId, 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(repoId, '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 client.getAgent().com.atproto.repo.getRecord({ repo: 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 client.getAgent().com.atproto.repo.getRecord({ repo: 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 updatedRecord: IssueRecord = { ...currentIssue, ...(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 client.getAgent().com.atproto.repo.getRecord({ repo: 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, repoId: string): Promise<number | undefined> { const match = displayId.match(/^#(\d+)$/); if (match) return Number.parseInt(match[1], 10);
const { issues } = await listIssues({ client, repoId, limit: 100 }); const sorted = issues.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); 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 { number: number | undefined; 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, repoId: string, stateOverride?: 'open' | 'closed'): Promise<IssueData> { const [issue, number] = await Promise.all([ getIssue({ client, issueUri }), resolveSequentialNumber(displayId, issueUri, client, repoId), ]); const state = stateOverride ?? (await getIssueState({ client, issueUri })); return { number, 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'); }}