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 363 lines 9.5 kB view raw
1import type { BlobRef } from '@atproto/lexicon'; 2import { parseAtUri } from '../utils/at-uri.js'; 3import { requireAuth } from '../utils/auth-helpers.js'; 4import type { TangledApiClient } from './api-client.js'; 5import { getBacklinks } from './constellation.js'; 6 7/** 8 * Pull request record type based on sh.tangled.repo.pull lexicon 9 */ 10export interface PullRecord { 11 $type: 'sh.tangled.repo.pull'; 12 target: { repo: string; branch: string }; 13 title: string; 14 body?: string; 15 patchBlob: BlobRef; 16 source?: { branch: string; sha: string; repo?: string }; 17 createdAt: string; 18 mentions?: string[]; 19 references?: string[]; 20 [key: string]: unknown; 21} 22 23/** 24 * Pull request record with metadata 25 */ 26export interface PullWithMetadata extends PullRecord { 27 uri: string; // AT-URI of the pull request 28 cid: string; // Content ID 29 author: string; // Creator's DID 30} 31 32/** 33 * Parameters for creating a pull request 34 */ 35export interface CreatePullParams { 36 client: TangledApiClient; 37 repoAtUri: string; 38 title: string; 39 body?: string; 40 targetBranch: string; 41 sourceBranch: string; 42 sourceSha: string; 43 patchBuffer: Buffer; 44} 45 46/** 47 * Parameters for listing pull requests 48 */ 49export interface ListPullsParams { 50 client: TangledApiClient; 51 repoAtUri: string; 52 limit?: number; 53 cursor?: string; 54} 55 56/** 57 * Parameters for getting a specific pull request 58 */ 59export interface GetPullParams { 60 client: TangledApiClient; 61 pullUri: string; 62} 63 64/** 65 * Parameters for getting pull request state 66 */ 67export interface GetPullStateParams { 68 client: TangledApiClient; 69 pullUri: string; 70} 71 72/** 73 * Canonical JSON shape for a single pull request, used by all pr commands. 74 */ 75export interface PullData { 76 number: number | undefined; 77 title: string; 78 body?: string; 79 state: 'open' | 'closed' | 'merged'; 80 author: string; 81 createdAt: string; 82 uri: string; 83 cid: string; 84 sourceBranch?: string; 85 targetBranch: string; 86} 87 88/** 89 * Parse and validate a pull request AT-URI 90 * @throws Error if URI is invalid or missing rkey 91 */ 92function parsePullUri(pullUri: string): { 93 did: string; 94 collection: string; 95 rkey: string; 96} { 97 const parsed = parseAtUri(pullUri); 98 if (!parsed || !parsed.rkey) { 99 throw new Error(`Invalid pull request AT-URI: ${pullUri}`); 100 } 101 102 return { 103 did: parsed.did, 104 collection: parsed.collection, 105 rkey: parsed.rkey, 106 }; 107} 108 109/** 110 * Create a new pull request 111 */ 112export async function createPull(params: CreatePullParams): Promise<PullWithMetadata> { 113 const { client, repoAtUri, title, body, targetBranch, sourceBranch, sourceSha, patchBuffer } = 114 params; 115 116 // Validate authentication 117 const session = await requireAuth(client); 118 119 try { 120 // Upload the gzip-compressed patch as a blob 121 const blobResponse = await client.getAgent().com.atproto.repo.uploadBlob(patchBuffer, { 122 encoding: 'application/gzip', 123 }); 124 const patchBlob = blobResponse.data.blob; 125 126 // Build pull request record 127 const record: PullRecord = { 128 $type: 'sh.tangled.repo.pull', 129 target: { 130 repo: repoAtUri, 131 branch: targetBranch, 132 }, 133 title, 134 body, 135 patchBlob, 136 source: { 137 branch: sourceBranch, 138 sha: sourceSha, 139 repo: repoAtUri, 140 }, 141 createdAt: new Date().toISOString(), 142 }; 143 144 // Create record via AT Protocol 145 const response = await client.getAgent().com.atproto.repo.createRecord({ 146 repo: session.did, 147 collection: 'sh.tangled.repo.pull', 148 record, 149 }); 150 151 return { 152 ...record, 153 uri: response.data.uri, 154 cid: response.data.cid, 155 author: session.did, 156 }; 157 } catch (error) { 158 if (error instanceof Error) { 159 throw new Error(`Failed to create pull request: ${error.message}`); 160 } 161 throw new Error('Failed to create pull request: Unknown error'); 162 } 163} 164 165/** 166 * List pull requests for a repository 167 */ 168export async function listPulls(params: ListPullsParams): Promise<{ 169 pulls: PullWithMetadata[]; 170 cursor?: string; 171}> { 172 const { client, repoAtUri, limit = 50, cursor } = params; 173 174 // Validate authentication 175 await requireAuth(client); 176 177 try { 178 // Query constellation for all pull requests that reference this repo 179 const backlinks = await getBacklinks( 180 repoAtUri, 181 'sh.tangled.repo.pull', 182 '.target.repo', 183 limit, 184 cursor 185 ); 186 187 // Fetch each pull request record individually 188 const pullPromises = backlinks.records.map(async ({ did, collection, rkey }) => { 189 const response = await client.getAgent().com.atproto.repo.getRecord({ 190 repo: did, 191 collection, 192 rkey, 193 }); 194 return { 195 ...(response.data.value as PullRecord), 196 uri: response.data.uri, 197 cid: response.data.cid as string, 198 author: did, 199 }; 200 }); 201 202 const pulls = await Promise.all(pullPromises); 203 204 return { 205 pulls, 206 cursor: backlinks.cursor ?? undefined, 207 }; 208 } catch (error) { 209 if (error instanceof Error) { 210 throw new Error(`Failed to list pull requests: ${error.message}`); 211 } 212 throw new Error('Failed to list pull requests: Unknown error'); 213 } 214} 215 216/** 217 * Get a specific pull request 218 */ 219export async function getPull(params: GetPullParams): Promise<PullWithMetadata> { 220 const { client, pullUri } = params; 221 222 // Validate authentication 223 await requireAuth(client); 224 225 // Parse pull URI 226 const { did, collection, rkey } = parsePullUri(pullUri); 227 228 try { 229 const response = await client.getAgent().com.atproto.repo.getRecord({ 230 repo: did, 231 collection, 232 rkey, 233 }); 234 235 const record = response.data.value as PullRecord; 236 237 return { 238 ...record, 239 uri: response.data.uri, 240 cid: response.data.cid as string, 241 author: did, 242 }; 243 } catch (error) { 244 if (error instanceof Error) { 245 if (error.message.includes('not found')) { 246 throw new Error(`Pull request not found: ${pullUri}`); 247 } 248 throw new Error(`Failed to get pull request: ${error.message}`); 249 } 250 throw new Error('Failed to get pull request: Unknown error'); 251 } 252} 253 254/** 255 * Get the state of a pull request (open, closed, or merged) 256 * @returns 'open', 'closed', or 'merged' (defaults to 'open' if no state record exists) 257 */ 258export async function getPullState( 259 params: GetPullStateParams 260): Promise<'open' | 'closed' | 'merged'> { 261 const { client, pullUri } = params; 262 263 // Validate authentication 264 await requireAuth(client); 265 266 try { 267 // Query constellation for all state records that reference this pull request 268 const backlinks = await getBacklinks(pullUri, 'sh.tangled.repo.pull.status', '.pull', 100); 269 270 if (backlinks.records.length === 0) { 271 return 'open'; 272 } 273 274 // Fetch each state record in parallel 275 const statePromises = backlinks.records.map(async ({ did, collection, rkey }) => { 276 const response = await client.getAgent().com.atproto.repo.getRecord({ 277 repo: did, 278 collection, 279 rkey, 280 }); 281 return { 282 rkey, 283 value: response.data.value as { 284 status?: 285 | 'sh.tangled.repo.pull.status.open' 286 | 'sh.tangled.repo.pull.status.closed' 287 | 'sh.tangled.repo.pull.status.merged'; 288 }, 289 }; 290 }); 291 292 const stateRecords = await Promise.all(statePromises); 293 294 // Sort by rkey ascending — TID rkeys are time-ordered, so the last is most recent 295 stateRecords.sort((a, b) => a.rkey.localeCompare(b.rkey)); 296 const latestState = stateRecords[stateRecords.length - 1]; 297 298 if (latestState.value.status === 'sh.tangled.repo.pull.status.closed') { 299 return 'closed'; 300 } 301 if (latestState.value.status === 'sh.tangled.repo.pull.status.merged') { 302 return 'merged'; 303 } 304 305 return 'open'; 306 } catch (error) { 307 if (error instanceof Error) { 308 throw new Error(`Failed to get pull request state: ${error.message}`); 309 } 310 throw new Error('Failed to get pull request state: Unknown error'); 311 } 312} 313 314/** 315 * Resolve a sequential pull request number from a displayId or by scanning the pull list. 316 * Fast path: if displayId is "#N", return N directly. 317 * Fallback: fetch all pulls, sort oldest-first, return 1-based position. 318 */ 319export async function resolveSequentialPullNumber( 320 displayId: string, 321 pullUri: string, 322 client: TangledApiClient, 323 repoAtUri: string 324): Promise<number | undefined> { 325 const match = displayId.match(/^#(\d+)$/); 326 if (match) return Number.parseInt(match[1], 10); 327 328 const { pulls } = await listPulls({ client, repoAtUri, limit: 100 }); 329 const sorted = pulls.sort( 330 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 331 ); 332 const idx = sorted.findIndex((p) => p.uri === pullUri); 333 return idx >= 0 ? idx + 1 : undefined; 334} 335 336/** 337 * Fetch a complete PullData object ready for JSON output. 338 * Fetches the pull record and sequential number in parallel. 339 */ 340export async function getCompletePullData( 341 client: TangledApiClient, 342 pullUri: string, 343 displayId: string, 344 repoAtUri: string 345): Promise<PullData> { 346 const [pull, number, state] = await Promise.all([ 347 getPull({ client, pullUri }), 348 resolveSequentialPullNumber(displayId, pullUri, client, repoAtUri), 349 getPullState({ client, pullUri }), 350 ]); 351 return { 352 number, 353 title: pull.title, 354 body: pull.body, 355 state, 356 author: pull.author, 357 createdAt: pull.createdAt, 358 uri: pull.uri, 359 cid: pull.cid, 360 sourceBranch: pull.source?.branch, 361 targetBranch: pull.target.branch, 362 }; 363}