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! :)

Add pr create, list, and view commands (issue #4)

Implements Phase 1 of pull request support: tangled pr create/list/view.
Patch is generated via git diff, gzip-compressed, and uploaded as a blob
before creating the sh.tangled.repo.pull record. Includes behind-base
detection that prompts interactively or exits in --json mode.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

markbennett.ca 859f9eb1 350b7d50

verified
+1571
+422
src/commands/pr.ts
··· 1 + import { promisify } from 'node:util'; 2 + import { gzip as gzipCallback } from 'node:zlib'; 3 + import { confirm } from '@inquirer/prompts'; 4 + import { Command } from 'commander'; 5 + import { simpleGit } from 'simple-git'; 6 + import { createApiClient } from '../lib/api-client.js'; 7 + import { getCurrentRepoContext } from '../lib/context.js'; 8 + import type { PullData } from '../lib/pulls-api.js'; 9 + import { createPull, getCompletePullData, getPullState, listPulls } from '../lib/pulls-api.js'; 10 + import { buildRepoAtUri } from '../utils/at-uri.js'; 11 + import { ensureAuthenticated, requireAuth } from '../utils/auth-helpers.js'; 12 + import { readBodyInput } from '../utils/body-input.js'; 13 + import { formatDate, outputJson } from '../utils/formatting.js'; 14 + 15 + const gzip = promisify(gzipCallback); 16 + 17 + /** 18 + * Format pull request state as a badge 19 + */ 20 + function formatPullState(state: 'open' | 'closed' | 'merged'): string { 21 + switch (state) { 22 + case 'open': 23 + return '[OPEN]'; 24 + case 'closed': 25 + return '[CLOSED]'; 26 + case 'merged': 27 + return '[MERGED]'; 28 + } 29 + } 30 + 31 + /** 32 + * Extract rkey from AT-URI 33 + */ 34 + function extractRkey(uri: string): string { 35 + const parts = uri.split('/'); 36 + return parts[parts.length - 1] || 'unknown'; 37 + } 38 + 39 + /** 40 + * Resolve PR number or rkey to full AT-URI 41 + * @param input - User input: number ("1"), hash ("#1"), or rkey ("3mef...") 42 + * @param client - API client 43 + * @param repoAtUri - Repository AT-URI 44 + */ 45 + async function resolvePullUri( 46 + input: string, 47 + client: ReturnType<typeof createApiClient>, 48 + repoAtUri: string 49 + ): Promise<{ uri: string; displayId: string }> { 50 + // Strip # prefix if present 51 + const normalized = input.startsWith('#') ? input.slice(1) : input; 52 + 53 + // Check if numeric 54 + if (/^\d+$/.test(normalized)) { 55 + const num = Number.parseInt(normalized, 10); 56 + 57 + if (num < 1) { 58 + throw new Error('Pull request number must be greater than 0'); 59 + } 60 + 61 + const { pulls } = await listPulls({ 62 + client, 63 + repoAtUri, 64 + limit: 100, 65 + }); 66 + 67 + // Sort by creation time (oldest first) 68 + const sorted = pulls.sort( 69 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 70 + ); 71 + 72 + const pull = sorted[num - 1]; 73 + if (!pull) { 74 + throw new Error(`Pull request #${num} not found`); 75 + } 76 + 77 + return { 78 + uri: pull.uri, 79 + displayId: `#${num}`, 80 + }; 81 + } 82 + 83 + // Treat as rkey - validate and build URI 84 + if (!/^[a-zA-Z0-9._-]+$/.test(normalized)) { 85 + throw new Error(`Invalid pull request identifier: ${input}`); 86 + } 87 + 88 + const session = await requireAuth(client); 89 + return { 90 + uri: `at://${session.did}/sh.tangled.repo.pull/${normalized}`, 91 + displayId: normalized, 92 + }; 93 + } 94 + 95 + /** 96 + * PR create subcommand 97 + */ 98 + function createCreateCommand(): Command { 99 + return new Command('create') 100 + .description('Create a new pull request') 101 + .argument('<title>', 'Pull request title') 102 + .option('-B, --base <branch>', 'Target branch to merge into', 'main') 103 + .option('-H, --head <branch>', 'Source branch with changes (default: current branch)') 104 + .option('-b, --body <string>', 'Pull request body text') 105 + .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 106 + .option('--skip-behind-check', 'Skip the check for unmerged base branch commits') 107 + .option('--json [fields]', 'Output JSON; optionally specify comma-separated fields') 108 + .action( 109 + async ( 110 + title: string, 111 + options: { 112 + base: string; 113 + head?: string; 114 + body?: string; 115 + bodyFile?: string; 116 + skipBehindCheck?: boolean; 117 + json?: string | true; 118 + } 119 + ) => { 120 + try { 121 + // 1. Validate auth 122 + const client = createApiClient(); 123 + await ensureAuthenticated(client); 124 + 125 + // 2. Get repo context 126 + const context = await getCurrentRepoContext(); 127 + if (!context) { 128 + console.error('✗ Not in a Tangled repository'); 129 + console.error('\nTo use this repository with Tangled, add a remote:'); 130 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 131 + process.exit(1); 132 + } 133 + 134 + const cwd = process.cwd(); 135 + const git = simpleGit(cwd); 136 + const baseBranch = options.base; 137 + 138 + // 3. Determine head branch 139 + const headBranch = options.head ?? (await git.revparse(['--abbrev-ref', 'HEAD'])).trim(); 140 + 141 + // 4. Get source SHA 142 + const sourceSha = (await git.revparse([headBranch])).trim(); 143 + 144 + // 5. Behind-base check 145 + if (!options.skipBehindCheck) { 146 + const behindLog = await git.log([`${headBranch}..${baseBranch}`]); 147 + const behindCount = behindLog.total; 148 + if (behindCount > 0) { 149 + const msg = `Head branch '${headBranch}' is ${behindCount} commit(s) behind '${baseBranch}'.`; 150 + if (options.json !== undefined) { 151 + // Non-interactive: fail with error 152 + console.error(`✗ ${msg} Merge base into head first, or use --skip-behind-check.`); 153 + process.exit(1); 154 + } else { 155 + // Interactive: prompt user 156 + console.warn(`⚠ ${msg}`); 157 + const proceed = await confirm({ 158 + message: 'Proceed anyway?', 159 + default: false, 160 + }); 161 + if (!proceed) { 162 + console.log('Aborted.'); 163 + process.exit(0); 164 + } 165 + } 166 + } 167 + } 168 + 169 + // 6. Generate patch 170 + const patchContent = await git.diff([`${baseBranch}..${headBranch}`]); 171 + if (!patchContent) { 172 + console.error( 173 + `✗ No diff found between '${baseBranch}' and '${headBranch}'. Branches may be identical.` 174 + ); 175 + process.exit(1); 176 + } 177 + 178 + // 7. Gzip the patch 179 + const patchBuffer = await gzip(Buffer.from(patchContent, 'utf-8')); 180 + 181 + // 8. Handle body input 182 + const body = await readBodyInput(options.body, options.bodyFile); 183 + 184 + // 9. Build repo AT-URI 185 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 186 + 187 + // 10. Create pull request 188 + if (options.json === undefined) { 189 + console.log('Creating pull request...'); 190 + } 191 + const pull = await createPull({ 192 + client, 193 + repoAtUri, 194 + title, 195 + body, 196 + targetBranch: baseBranch, 197 + sourceBranch: headBranch, 198 + sourceSha, 199 + patchBuffer, 200 + }); 201 + 202 + // 11. Compute sequential number 203 + const { pulls: allPulls } = await listPulls({ client, repoAtUri, limit: 100 }); 204 + const sortedAll = allPulls.sort( 205 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 206 + ); 207 + const idx = sortedAll.findIndex((p) => p.uri === pull.uri); 208 + const number = idx >= 0 ? idx + 1 : undefined; 209 + 210 + // 12. Output result 211 + if (options.json !== undefined) { 212 + const pullData: PullData = { 213 + number, 214 + title: pull.title, 215 + body: pull.body, 216 + state: 'open', 217 + author: pull.author, 218 + createdAt: pull.createdAt, 219 + uri: pull.uri, 220 + cid: pull.cid, 221 + sourceBranch: pull.source?.branch, 222 + targetBranch: pull.target.branch, 223 + }; 224 + outputJson(pullData, typeof options.json === 'string' ? options.json : undefined); 225 + return; 226 + } 227 + 228 + const displayNumber = number !== undefined ? `#${number}` : extractRkey(pull.uri); 229 + console.log(`\n✓ Pull request ${displayNumber} created`); 230 + console.log(` Title: ${pull.title}`); 231 + console.log(` ${headBranch} → ${baseBranch}`); 232 + console.log(` URI: ${pull.uri}`); 233 + } catch (error) { 234 + console.error( 235 + `✗ Failed to create pull request: ${error instanceof Error ? error.message : 'Unknown error'}` 236 + ); 237 + process.exit(1); 238 + } 239 + } 240 + ); 241 + } 242 + 243 + /** 244 + * PR list subcommand 245 + */ 246 + function createListCommand(): Command { 247 + return new Command('list') 248 + .description('List pull requests for the current repository') 249 + .option('-l, --limit <number>', 'Maximum number of pull requests to fetch', '50') 250 + .option('--json [fields]', 'Output JSON; optionally specify comma-separated fields') 251 + .action(async (options: { limit: string; json?: string | true }) => { 252 + try { 253 + // 1. Validate auth 254 + const client = createApiClient(); 255 + await ensureAuthenticated(client); 256 + 257 + // 2. Get repo context 258 + const context = await getCurrentRepoContext(); 259 + if (!context) { 260 + console.error('✗ Not in a Tangled repository'); 261 + console.error('\nTo use this repository with Tangled, add a remote:'); 262 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 263 + process.exit(1); 264 + } 265 + 266 + // 3. Build repo AT-URI 267 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 268 + 269 + // 4. Fetch pull requests 270 + const limit = Number.parseInt(options.limit, 10); 271 + if (Number.isNaN(limit) || limit < 1 || limit > 100) { 272 + console.error('✗ Invalid limit. Must be between 1 and 100.'); 273 + process.exit(1); 274 + } 275 + 276 + const { pulls } = await listPulls({ 277 + client, 278 + repoAtUri, 279 + limit, 280 + }); 281 + 282 + // 5. Handle empty results 283 + if (pulls.length === 0) { 284 + if (options.json !== undefined) { 285 + console.log('[]'); 286 + } else { 287 + console.log('No pull requests found for this repository.'); 288 + } 289 + return; 290 + } 291 + 292 + // Sort pull requests by creation time (oldest first) for consistent numbering 293 + const sortedPulls = pulls.sort( 294 + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 295 + ); 296 + 297 + // Build pull data with states (in parallel for performance) 298 + const pullData = await Promise.all( 299 + sortedPulls.map(async (pull, i) => { 300 + const state = await getPullState({ client, pullUri: pull.uri }); 301 + return { 302 + number: i + 1, 303 + title: pull.title, 304 + body: pull.body, 305 + state, 306 + author: pull.author, 307 + createdAt: pull.createdAt, 308 + uri: pull.uri, 309 + cid: pull.cid, 310 + sourceBranch: pull.source?.branch, 311 + targetBranch: pull.target.branch, 312 + }; 313 + }) 314 + ); 315 + 316 + // 6. Output results 317 + if (options.json !== undefined) { 318 + outputJson(pullData, typeof options.json === 'string' ? options.json : undefined); 319 + return; 320 + } 321 + 322 + console.log( 323 + `\nFound ${pullData.length} pull request${pullData.length === 1 ? '' : 's'}:\n` 324 + ); 325 + 326 + for (const item of pullData) { 327 + const stateBadge = formatPullState(item.state); 328 + const date = formatDate(item.createdAt); 329 + const branches = item.sourceBranch 330 + ? `${item.sourceBranch} → ${item.targetBranch}` 331 + : item.targetBranch; 332 + console.log(` #${item.number} ${stateBadge} ${item.title}`); 333 + console.log(` ${branches} · Created ${date}`); 334 + console.log(); 335 + } 336 + } catch (error) { 337 + console.error( 338 + `✗ Failed to list pull requests: ${error instanceof Error ? error.message : 'Unknown error'}` 339 + ); 340 + process.exit(1); 341 + } 342 + }); 343 + } 344 + 345 + /** 346 + * PR view subcommand 347 + */ 348 + function createViewCommand(): Command { 349 + return new Command('view') 350 + .description('View details of a specific pull request') 351 + .argument('<pr-id>', 'Pull request number (e.g., 1, #2) or rkey') 352 + .option('--json [fields]', 'Output JSON; optionally specify comma-separated fields') 353 + .action(async (prId: string, options: { json?: string | true }) => { 354 + try { 355 + // 1. Validate auth 356 + const client = createApiClient(); 357 + await ensureAuthenticated(client); 358 + 359 + // 2. Get repo context 360 + const context = await getCurrentRepoContext(); 361 + if (!context) { 362 + console.error('✗ Not in a Tangled repository'); 363 + console.error('\nTo use this repository with Tangled, add a remote:'); 364 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 365 + process.exit(1); 366 + } 367 + 368 + // 3. Build repo AT-URI 369 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 370 + 371 + // 4. Resolve PR ID to URI 372 + const { uri: pullUri, displayId } = await resolvePullUri(prId, client, repoAtUri); 373 + 374 + // 5. Fetch complete pull request data 375 + const pullData = await getCompletePullData(client, pullUri, displayId, repoAtUri); 376 + 377 + // 6. Output result 378 + if (options.json !== undefined) { 379 + outputJson(pullData, typeof options.json === 'string' ? options.json : undefined); 380 + return; 381 + } 382 + 383 + const branches = pullData.sourceBranch 384 + ? `${pullData.sourceBranch} → ${pullData.targetBranch}` 385 + : pullData.targetBranch; 386 + 387 + console.log(`\nPR ${displayId} ${formatPullState(pullData.state)}`); 388 + console.log(`Title: ${pullData.title}`); 389 + console.log(`Branches: ${branches}`); 390 + console.log(`Author: ${pullData.author}`); 391 + console.log(`Created: ${formatDate(pullData.createdAt)}`); 392 + console.log(`Repo: ${context.name}`); 393 + console.log(`URI: ${pullData.uri}`); 394 + 395 + if (pullData.body) { 396 + console.log('\nBody:'); 397 + console.log(pullData.body); 398 + } 399 + 400 + console.log(); // Empty line at end 401 + } catch (error) { 402 + console.error( 403 + `✗ Failed to view pull request: ${error instanceof Error ? error.message : 'Unknown error'}` 404 + ); 405 + process.exit(1); 406 + } 407 + }); 408 + } 409 + 410 + /** 411 + * Create the pr command with all subcommands 412 + */ 413 + export function createPrCommand(): Command { 414 + const pr = new Command('pr'); 415 + pr.description('Manage pull requests in Tangled repositories'); 416 + 417 + pr.addCommand(createCreateCommand()); 418 + pr.addCommand(createListCommand()); 419 + pr.addCommand(createViewCommand()); 420 + 421 + return pr; 422 + }
+2
src/index.ts
··· 7 7 import { createConfigCommand } from './commands/config.js'; 8 8 import { createContextCommand } from './commands/context.js'; 9 9 import { createIssueCommand } from './commands/issue.js'; 10 + import { createPrCommand } from './commands/pr.js'; 10 11 import { createSshKeyCommand } from './commands/ssh-key.js'; 11 12 12 13 // Get package.json for version ··· 27 28 program.addCommand(createConfigCommand()); 28 29 program.addCommand(createContextCommand()); 29 30 program.addCommand(createIssueCommand()); 31 + program.addCommand(createPrCommand()); 30 32 31 33 program.parse(process.argv);
+363
src/lib/pulls-api.ts
··· 1 + import type { BlobRef } from '@atproto/lexicon'; 2 + import { parseAtUri } from '../utils/at-uri.js'; 3 + import { requireAuth } from '../utils/auth-helpers.js'; 4 + import type { TangledApiClient } from './api-client.js'; 5 + import { getBacklinks } from './constellation.js'; 6 + 7 + /** 8 + * Pull request record type based on sh.tangled.repo.pull lexicon 9 + */ 10 + export 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 + */ 26 + export 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 + */ 35 + export 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 + */ 49 + export 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 + */ 59 + export interface GetPullParams { 60 + client: TangledApiClient; 61 + pullUri: string; 62 + } 63 + 64 + /** 65 + * Parameters for getting pull request state 66 + */ 67 + export 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 + */ 75 + export 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 + */ 92 + function 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 + */ 112 + export 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 + */ 168 + export 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 + */ 219 + export 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 + */ 258 + export 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 + */ 319 + export 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 + */ 340 + export 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 + }
+379
tests/commands/pr.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import { createPrCommand } from '../../src/commands/pr.js'; 3 + import type { TangledApiClient } from '../../src/lib/api-client.js'; 4 + import * as apiClient from '../../src/lib/api-client.js'; 5 + import * as context from '../../src/lib/context.js'; 6 + import type { PullWithMetadata } from '../../src/lib/pulls-api.js'; 7 + import * as pullsApi from '../../src/lib/pulls-api.js'; 8 + import * as atUri from '../../src/utils/at-uri.js'; 9 + import * as authHelpers from '../../src/utils/auth-helpers.js'; 10 + import * as bodyInput from '../../src/utils/body-input.js'; 11 + 12 + // Mock dependencies 13 + vi.mock('../../src/lib/api-client.js'); 14 + vi.mock('../../src/lib/pulls-api.js'); 15 + vi.mock('../../src/lib/context.js'); 16 + vi.mock('../../src/utils/at-uri.js'); 17 + vi.mock('../../src/utils/body-input.js'); 18 + vi.mock('../../src/utils/auth-helpers.js'); 19 + vi.mock('@inquirer/prompts'); 20 + vi.mock('simple-git'); 21 + vi.mock('node:zlib'); 22 + 23 + const REPO_AT_URI = 'at://did:plc:abc123/sh.tangled.repo/test-repo'; 24 + const PULL_AT_URI = 'at://did:plc:abc123/sh.tangled.repo.pull/pull123'; 25 + 26 + const makePull = (overrides: Partial<PullWithMetadata> = {}): PullWithMetadata => ({ 27 + $type: 'sh.tangled.repo.pull', 28 + target: { repo: REPO_AT_URI, branch: 'main' }, 29 + title: 'Test PR', 30 + patchBlob: {} as never, 31 + source: { branch: 'feature/test', sha: 'abc123sha', repo: REPO_AT_URI }, 32 + createdAt: '2024-01-01T00:00:00.000Z', 33 + uri: PULL_AT_URI, 34 + cid: 'bafyreiabc123', 35 + author: 'did:plc:abc123', 36 + ...overrides, 37 + }); 38 + 39 + describe('pr list command', () => { 40 + let mockClient: TangledApiClient; 41 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 42 + 43 + beforeEach(() => { 44 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 45 + vi.spyOn(console, 'error').mockImplementation(() => {}); 46 + vi.spyOn(process, 'exit').mockImplementation((code) => { 47 + throw new Error(`process.exit(${code})`); 48 + }) as never; 49 + 50 + mockClient = { resumeSession: vi.fn(async () => true) } as unknown as TangledApiClient; 51 + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 52 + 53 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 54 + owner: 'test.bsky.social', 55 + ownerType: 'handle', 56 + name: 'test-repo', 57 + remoteName: 'origin', 58 + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 59 + protocol: 'ssh', 60 + }); 61 + 62 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue(REPO_AT_URI); 63 + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [], cursor: undefined }); 64 + vi.mocked(pullsApi.getPullState).mockResolvedValue('open'); 65 + vi.mocked(authHelpers.ensureAuthenticated).mockResolvedValue(undefined); 66 + }); 67 + 68 + afterEach(() => { 69 + vi.restoreAllMocks(); 70 + }); 71 + 72 + it('should show empty message when no pull requests exist', async () => { 73 + const command = createPrCommand(); 74 + await command.parseAsync(['node', 'test', 'list']); 75 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No pull requests found')); 76 + }); 77 + 78 + it('should list pull requests with number and title', async () => { 79 + const pull = makePull(); 80 + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [pull], cursor: undefined }); 81 + 82 + const command = createPrCommand(); 83 + await command.parseAsync(['node', 'test', 'list']); 84 + 85 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('#1')); 86 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Test PR')); 87 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[OPEN]')); 88 + }); 89 + 90 + it('should output JSON when --json flag is provided', async () => { 91 + const pull = makePull(); 92 + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [pull], cursor: undefined }); 93 + 94 + const command = createPrCommand(); 95 + await command.parseAsync(['node', 'test', 'list', '--json']); 96 + 97 + const jsonOutput = consoleLogSpy.mock.calls.find((call) => { 98 + try { 99 + const parsed = JSON.parse(String(call[0])); 100 + return Array.isArray(parsed); 101 + } catch { 102 + return false; 103 + } 104 + }); 105 + expect(jsonOutput).toBeDefined(); 106 + }); 107 + 108 + it('should output [] JSON when no pulls and --json', async () => { 109 + const command = createPrCommand(); 110 + await command.parseAsync(['node', 'test', 'list', '--json']); 111 + expect(consoleLogSpy).toHaveBeenCalledWith('[]'); 112 + }); 113 + 114 + it('should exit 1 when not in a Tangled repository', async () => { 115 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); 116 + const command = createPrCommand(); 117 + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 118 + }); 119 + 120 + it('should exit 1 when auth fails', async () => { 121 + vi.mocked(authHelpers.ensureAuthenticated).mockRejectedValue(new Error('Not authenticated')); 122 + const command = createPrCommand(); 123 + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 124 + }); 125 + }); 126 + 127 + describe('pr view command', () => { 128 + let mockClient: TangledApiClient; 129 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 130 + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 131 + 132 + beforeEach(() => { 133 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 134 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 135 + vi.spyOn(process, 'exit').mockImplementation((code) => { 136 + throw new Error(`process.exit(${code})`); 137 + }) as never; 138 + 139 + mockClient = { resumeSession: vi.fn(async () => true) } as unknown as TangledApiClient; 140 + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 141 + 142 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 143 + owner: 'test.bsky.social', 144 + ownerType: 'handle', 145 + name: 'test-repo', 146 + remoteName: 'origin', 147 + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 148 + protocol: 'ssh', 149 + }); 150 + 151 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue(REPO_AT_URI); 152 + vi.mocked(authHelpers.ensureAuthenticated).mockResolvedValue(undefined); 153 + 154 + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [makePull()], cursor: undefined }); 155 + vi.mocked(pullsApi.getCompletePullData).mockResolvedValue({ 156 + number: 1, 157 + title: 'Test PR', 158 + body: 'Description', 159 + state: 'open', 160 + author: 'did:plc:abc123', 161 + createdAt: '2024-01-01T00:00:00.000Z', 162 + uri: PULL_AT_URI, 163 + cid: 'bafyreiabc123', 164 + sourceBranch: 'feature/test', 165 + targetBranch: 'main', 166 + }); 167 + }); 168 + 169 + afterEach(() => { 170 + vi.restoreAllMocks(); 171 + }); 172 + 173 + it('should display pull request details', async () => { 174 + const command = createPrCommand(); 175 + await command.parseAsync(['node', 'test', 'view', '1']); 176 + 177 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Test PR')); 178 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[OPEN]')); 179 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('feature/test → main')); 180 + }); 181 + 182 + it('should output JSON when --json flag is provided', async () => { 183 + const command = createPrCommand(); 184 + await command.parseAsync(['node', 'test', 'view', '1', '--json']); 185 + 186 + const jsonOutput = consoleLogSpy.mock.calls.find((call) => { 187 + try { 188 + const parsed = JSON.parse(String(call[0])); 189 + return typeof parsed === 'object' && parsed !== null; 190 + } catch { 191 + return false; 192 + } 193 + }); 194 + expect(jsonOutput).toBeDefined(); 195 + }); 196 + 197 + it('should exit 1 for pull request not found', async () => { 198 + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [], cursor: undefined }); 199 + const command = createPrCommand(); 200 + await expect(command.parseAsync(['node', 'test', 'view', '99'])).rejects.toThrow( 201 + 'process.exit(1)' 202 + ); 203 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('not found')); 204 + }); 205 + }); 206 + 207 + describe('pr create command', () => { 208 + let mockClient: TangledApiClient; 209 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 210 + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 211 + 212 + beforeEach(async () => { 213 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 214 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 215 + vi.spyOn(process, 'exit').mockImplementation((code) => { 216 + throw new Error(`process.exit(${code})`); 217 + }) as never; 218 + 219 + mockClient = { resumeSession: vi.fn(async () => true) } as unknown as TangledApiClient; 220 + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 221 + 222 + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 223 + owner: 'test.bsky.social', 224 + ownerType: 'handle', 225 + name: 'test-repo', 226 + remoteName: 'origin', 227 + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 228 + protocol: 'ssh', 229 + }); 230 + 231 + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue(REPO_AT_URI); 232 + vi.mocked(authHelpers.ensureAuthenticated).mockResolvedValue(undefined); 233 + vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 234 + 235 + // Mock simple-git 236 + const { simpleGit } = await import('simple-git'); 237 + vi.mocked(simpleGit).mockReturnValue({ 238 + revparse: vi.fn().mockResolvedValue('feature/test\n'), 239 + log: vi.fn().mockResolvedValue({ total: 0, all: [] }), 240 + diff: vi.fn().mockResolvedValue('diff --git a/file.ts b/file.ts\n+new line\n'), 241 + } as never); 242 + 243 + // Mock gzip 244 + const zlib = await import('node:zlib'); 245 + vi.mocked(zlib.gzip).mockImplementation((_buf, cb) => { 246 + (cb as (err: null, result: Buffer) => void)(null, Buffer.from('gzip-compressed')); 247 + }); 248 + 249 + const pull = makePull(); 250 + vi.mocked(pullsApi.createPull).mockResolvedValue(pull); 251 + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [pull], cursor: undefined }); 252 + }); 253 + 254 + afterEach(() => { 255 + vi.restoreAllMocks(); 256 + }); 257 + 258 + it('should create a pull request and display success', async () => { 259 + const command = createPrCommand(); 260 + await command.parseAsync([ 261 + 'node', 262 + 'test', 263 + 'create', 264 + 'Test PR', 265 + '--base', 266 + 'main', 267 + '--head', 268 + 'feature/test', 269 + ]); 270 + 271 + expect(pullsApi.createPull).toHaveBeenCalledWith( 272 + expect.objectContaining({ 273 + title: 'Test PR', 274 + targetBranch: 'main', 275 + sourceBranch: 'feature/test', 276 + }) 277 + ); 278 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('✓')); 279 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Test PR')); 280 + }); 281 + 282 + it('should exit 1 when no diff between branches', async () => { 283 + const { simpleGit } = await import('simple-git'); 284 + vi.mocked(simpleGit).mockReturnValue({ 285 + revparse: vi.fn().mockResolvedValue('feature/test\n'), 286 + log: vi.fn().mockResolvedValue({ total: 0, all: [] }), 287 + diff: vi.fn().mockResolvedValue(''), 288 + } as never); 289 + 290 + const command = createPrCommand(); 291 + await expect( 292 + command.parseAsync([ 293 + 'node', 294 + 'test', 295 + 'create', 296 + 'Empty PR', 297 + '--base', 298 + 'main', 299 + '--head', 300 + 'feature/test', 301 + ]) 302 + ).rejects.toThrow('process.exit(1)'); 303 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No diff found')); 304 + }); 305 + 306 + it('should output JSON when --json flag is provided', async () => { 307 + const command = createPrCommand(); 308 + await command.parseAsync([ 309 + 'node', 310 + 'test', 311 + 'create', 312 + 'Test PR', 313 + '--base', 314 + 'main', 315 + '--head', 316 + 'feature/test', 317 + '--json', 318 + ]); 319 + 320 + const jsonOutput = consoleLogSpy.mock.calls.find((call) => { 321 + try { 322 + const parsed = JSON.parse(String(call[0])); 323 + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed); 324 + } catch { 325 + return false; 326 + } 327 + }); 328 + expect(jsonOutput).toBeDefined(); 329 + }); 330 + 331 + it('should exit 1 in non-interactive mode when behind base', async () => { 332 + const { simpleGit } = await import('simple-git'); 333 + vi.mocked(simpleGit).mockReturnValue({ 334 + revparse: vi.fn().mockResolvedValue('feature/test\n'), 335 + log: vi.fn().mockResolvedValue({ total: 3, all: [] }), 336 + diff: vi.fn().mockResolvedValue('some diff'), 337 + } as never); 338 + 339 + const command = createPrCommand(); 340 + await expect( 341 + command.parseAsync([ 342 + 'node', 343 + 'test', 344 + 'create', 345 + 'Test PR', 346 + '--base', 347 + 'main', 348 + '--head', 349 + 'feature/test', 350 + '--json', 351 + ]) 352 + ).rejects.toThrow('process.exit(1)'); 353 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('behind')); 354 + }); 355 + 356 + it('should proceed when --skip-behind-check is provided even if behind', async () => { 357 + const { simpleGit } = await import('simple-git'); 358 + vi.mocked(simpleGit).mockReturnValue({ 359 + revparse: vi.fn().mockResolvedValue('feature/test\n'), 360 + log: vi.fn().mockResolvedValue({ total: 3, all: [] }), 361 + diff: vi.fn().mockResolvedValue('some diff content'), 362 + } as never); 363 + 364 + const command = createPrCommand(); 365 + await command.parseAsync([ 366 + 'node', 367 + 'test', 368 + 'create', 369 + 'Test PR', 370 + '--base', 371 + 'main', 372 + '--head', 373 + 'feature/test', 374 + '--skip-behind-check', 375 + ]); 376 + 377 + expect(pullsApi.createPull).toHaveBeenCalled(); 378 + }); 379 + });
+405
tests/lib/pulls-api.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import type { TangledApiClient } from '../../src/lib/api-client.js'; 3 + import { getBacklinks } from '../../src/lib/constellation.js'; 4 + import { 5 + createPull, 6 + getPull, 7 + getPullState, 8 + listPulls, 9 + resolveSequentialPullNumber, 10 + } from '../../src/lib/pulls-api.js'; 11 + 12 + vi.mock('../../src/lib/constellation.js'); 13 + 14 + // Mock API client factory 15 + const createMockClient = (authenticated = true): TangledApiClient => { 16 + const mockAgent = { 17 + com: { 18 + atproto: { 19 + repo: { 20 + createRecord: vi.fn(), 21 + getRecord: vi.fn(), 22 + uploadBlob: vi.fn(), 23 + }, 24 + }, 25 + }, 26 + }; 27 + 28 + return { 29 + isAuthenticated: vi.fn(() => authenticated), 30 + getSession: vi.fn(() => 31 + authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null 32 + ), 33 + getAgent: vi.fn(() => mockAgent), 34 + } as unknown as TangledApiClient; 35 + }; 36 + 37 + const REPO_AT_URI = 'at://did:plc:owner/sh.tangled.repo/my-repo'; 38 + const PULL_AT_URI = 'at://did:plc:test123/sh.tangled.repo.pull/abc123'; 39 + 40 + describe('createPull', () => { 41 + let mockClient: TangledApiClient; 42 + 43 + beforeEach(() => { 44 + mockClient = createMockClient(true); 45 + }); 46 + 47 + it('should upload blob and create pull record', async () => { 48 + const mockBlob = { 49 + $type: 'blob', 50 + ref: { $link: 'bafyreiabc123' }, 51 + mimeType: 'application/gzip', 52 + size: 42, 53 + }; 54 + const mockUploadBlob = vi.fn().mockResolvedValue({ data: { blob: mockBlob } }); 55 + const mockCreateRecord = vi.fn().mockResolvedValue({ 56 + data: { 57 + uri: PULL_AT_URI, 58 + cid: 'cid123', 59 + }, 60 + }); 61 + 62 + vi.mocked(mockClient.getAgent).mockReturnValue({ 63 + com: { 64 + atproto: { 65 + repo: { 66 + uploadBlob: mockUploadBlob, 67 + createRecord: mockCreateRecord, 68 + }, 69 + }, 70 + }, 71 + } as never); 72 + 73 + const patchBuffer = Buffer.from('fake gzip content'); 74 + const result = await createPull({ 75 + client: mockClient, 76 + repoAtUri: REPO_AT_URI, 77 + title: 'Add new feature', 78 + body: 'Description', 79 + targetBranch: 'main', 80 + sourceBranch: 'feature/new-thing', 81 + sourceSha: 'abc123sha', 82 + patchBuffer, 83 + }); 84 + 85 + expect(mockUploadBlob).toHaveBeenCalledWith(patchBuffer, { encoding: 'application/gzip' }); 86 + expect(mockCreateRecord).toHaveBeenCalledWith({ 87 + repo: 'did:plc:test123', 88 + collection: 'sh.tangled.repo.pull', 89 + record: expect.objectContaining({ 90 + $type: 'sh.tangled.repo.pull', 91 + target: { repo: REPO_AT_URI, branch: 'main' }, 92 + title: 'Add new feature', 93 + body: 'Description', 94 + patchBlob: mockBlob, 95 + source: { branch: 'feature/new-thing', sha: 'abc123sha', repo: REPO_AT_URI }, 96 + createdAt: expect.any(String), 97 + }), 98 + }); 99 + 100 + expect(result).toMatchObject({ 101 + uri: PULL_AT_URI, 102 + cid: 'cid123', 103 + author: 'did:plc:test123', 104 + title: 'Add new feature', 105 + }); 106 + }); 107 + 108 + it('should create pull without body', async () => { 109 + const mockBlob = { 110 + $type: 'blob', 111 + ref: { $link: 'bafyreiabc123' }, 112 + mimeType: 'application/gzip', 113 + size: 10, 114 + }; 115 + const mockUploadBlob = vi.fn().mockResolvedValue({ data: { blob: mockBlob } }); 116 + const mockCreateRecord = vi.fn().mockResolvedValue({ 117 + data: { uri: PULL_AT_URI, cid: 'cid123' }, 118 + }); 119 + 120 + vi.mocked(mockClient.getAgent).mockReturnValue({ 121 + com: { atproto: { repo: { uploadBlob: mockUploadBlob, createRecord: mockCreateRecord } } }, 122 + } as never); 123 + 124 + const result = await createPull({ 125 + client: mockClient, 126 + repoAtUri: REPO_AT_URI, 127 + title: 'Fix bug', 128 + targetBranch: 'main', 129 + sourceBranch: 'fix/bug', 130 + sourceSha: 'deadbeef', 131 + patchBuffer: Buffer.from('patch'), 132 + }); 133 + 134 + expect(result.body).toBeUndefined(); 135 + expect(result.title).toBe('Fix bug'); 136 + }); 137 + 138 + it('should throw when not authenticated', async () => { 139 + const unauthClient = createMockClient(false); 140 + await expect( 141 + createPull({ 142 + client: unauthClient, 143 + repoAtUri: REPO_AT_URI, 144 + title: 'Test', 145 + targetBranch: 'main', 146 + sourceBranch: 'feature', 147 + sourceSha: 'abc', 148 + patchBuffer: Buffer.from('patch'), 149 + }) 150 + ).rejects.toThrow(); 151 + }); 152 + }); 153 + 154 + describe('listPulls', () => { 155 + let mockClient: TangledApiClient; 156 + 157 + beforeEach(() => { 158 + mockClient = createMockClient(true); 159 + vi.mocked(getBacklinks).mockResolvedValue({ 160 + total: 0, 161 + records: [], 162 + cursor: null, 163 + }); 164 + }); 165 + 166 + it('should return empty list when no pulls exist', async () => { 167 + const result = await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI }); 168 + expect(result.pulls).toHaveLength(0); 169 + expect(result.cursor).toBeUndefined(); 170 + }); 171 + 172 + it('should query constellation with correct parameters', async () => { 173 + await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI, limit: 25 }); 174 + expect(getBacklinks).toHaveBeenCalledWith( 175 + REPO_AT_URI, 176 + 'sh.tangled.repo.pull', 177 + '.target.repo', 178 + 25, 179 + undefined 180 + ); 181 + }); 182 + 183 + it('should fetch records from backlinks and return pulls', async () => { 184 + vi.mocked(getBacklinks).mockResolvedValue({ 185 + total: 1, 186 + records: [{ did: 'did:plc:test123', collection: 'sh.tangled.repo.pull', rkey: 'abc123' }], 187 + cursor: null, 188 + }); 189 + 190 + const mockRecord = { 191 + $type: 'sh.tangled.repo.pull', 192 + target: { repo: REPO_AT_URI, branch: 'main' }, 193 + title: 'Test PR', 194 + patchBlob: {}, 195 + source: { branch: 'feature', sha: 'abc', repo: REPO_AT_URI }, 196 + createdAt: '2024-01-01T00:00:00.000Z', 197 + }; 198 + 199 + const mockGetRecord = vi.fn().mockResolvedValue({ 200 + data: { value: mockRecord, uri: PULL_AT_URI, cid: 'cid123' }, 201 + }); 202 + 203 + vi.mocked(mockClient.getAgent).mockReturnValue({ 204 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 205 + } as never); 206 + 207 + const result = await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI }); 208 + expect(result.pulls).toHaveLength(1); 209 + expect(result.pulls[0].title).toBe('Test PR'); 210 + expect(result.pulls[0].uri).toBe(PULL_AT_URI); 211 + expect(result.pulls[0].author).toBe('did:plc:test123'); 212 + }); 213 + }); 214 + 215 + describe('getPull', () => { 216 + let mockClient: TangledApiClient; 217 + 218 + beforeEach(() => { 219 + mockClient = createMockClient(true); 220 + }); 221 + 222 + it('should fetch pull record by AT-URI', async () => { 223 + const mockRecord = { 224 + $type: 'sh.tangled.repo.pull', 225 + target: { repo: REPO_AT_URI, branch: 'main' }, 226 + title: 'Test PR', 227 + patchBlob: {}, 228 + createdAt: '2024-01-01T00:00:00.000Z', 229 + }; 230 + 231 + const mockGetRecord = vi.fn().mockResolvedValue({ 232 + data: { value: mockRecord, uri: PULL_AT_URI, cid: 'cid123' }, 233 + }); 234 + 235 + vi.mocked(mockClient.getAgent).mockReturnValue({ 236 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 237 + } as never); 238 + 239 + const result = await getPull({ client: mockClient, pullUri: PULL_AT_URI }); 240 + expect(result.title).toBe('Test PR'); 241 + expect(result.uri).toBe(PULL_AT_URI); 242 + expect(result.author).toBe('did:plc:test123'); 243 + expect(mockGetRecord).toHaveBeenCalledWith({ 244 + repo: 'did:plc:test123', 245 + collection: 'sh.tangled.repo.pull', 246 + rkey: 'abc123', 247 + }); 248 + }); 249 + 250 + it('should throw for invalid AT-URI', async () => { 251 + await expect(getPull({ client: mockClient, pullUri: 'not-a-uri' })).rejects.toThrow( 252 + 'Invalid pull request AT-URI' 253 + ); 254 + }); 255 + }); 256 + 257 + describe('getPullState', () => { 258 + let mockClient: TangledApiClient; 259 + 260 + beforeEach(() => { 261 + mockClient = createMockClient(true); 262 + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); 263 + }); 264 + 265 + it('should return open when no state records exist', async () => { 266 + const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI }); 267 + expect(state).toBe('open'); 268 + expect(getBacklinks).toHaveBeenCalledWith( 269 + PULL_AT_URI, 270 + 'sh.tangled.repo.pull.status', 271 + '.pull', 272 + 100 273 + ); 274 + }); 275 + 276 + it('should return closed for closed status', async () => { 277 + vi.mocked(getBacklinks).mockResolvedValue({ 278 + total: 1, 279 + records: [ 280 + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey1' }, 281 + ], 282 + cursor: null, 283 + }); 284 + const mockGetRecord = vi.fn().mockResolvedValue({ 285 + data: { value: { status: 'sh.tangled.repo.pull.status.closed' }, uri: '', cid: '' }, 286 + }); 287 + vi.mocked(mockClient.getAgent).mockReturnValue({ 288 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 289 + } as never); 290 + 291 + const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI }); 292 + expect(state).toBe('closed'); 293 + }); 294 + 295 + it('should return merged for merged status', async () => { 296 + vi.mocked(getBacklinks).mockResolvedValue({ 297 + total: 1, 298 + records: [ 299 + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey1' }, 300 + ], 301 + cursor: null, 302 + }); 303 + const mockGetRecord = vi.fn().mockResolvedValue({ 304 + data: { value: { status: 'sh.tangled.repo.pull.status.merged' }, uri: '', cid: '' }, 305 + }); 306 + vi.mocked(mockClient.getAgent).mockReturnValue({ 307 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 308 + } as never); 309 + 310 + const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI }); 311 + expect(state).toBe('merged'); 312 + }); 313 + 314 + it('should use latest rkey when multiple state records exist', async () => { 315 + vi.mocked(getBacklinks).mockResolvedValue({ 316 + total: 2, 317 + records: [ 318 + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey2' }, 319 + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey1' }, 320 + ], 321 + cursor: null, 322 + }); 323 + const mockGetRecord = vi 324 + .fn() 325 + .mockResolvedValueOnce({ 326 + data: { value: { status: 'sh.tangled.repo.pull.status.closed' }, uri: '', cid: '' }, 327 + }) 328 + .mockResolvedValueOnce({ 329 + data: { value: { status: 'sh.tangled.repo.pull.status.open' }, uri: '', cid: '' }, 330 + }); 331 + vi.mocked(mockClient.getAgent).mockReturnValue({ 332 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 333 + } as never); 334 + 335 + // rkey2 > rkey1 alphabetically, so rkey2 (closed) should win 336 + const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI }); 337 + expect(state).toBe('closed'); 338 + }); 339 + }); 340 + 341 + describe('resolveSequentialPullNumber', () => { 342 + let mockClient: TangledApiClient; 343 + 344 + beforeEach(() => { 345 + mockClient = createMockClient(true); 346 + vi.mocked(getBacklinks).mockClear(); 347 + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); 348 + }); 349 + 350 + it('should use fast path for #N displayId', async () => { 351 + const num = await resolveSequentialPullNumber('#3', PULL_AT_URI, mockClient, REPO_AT_URI); 352 + expect(num).toBe(3); 353 + expect(getBacklinks).not.toHaveBeenCalled(); 354 + }); 355 + 356 + it('should scan pulls when displayId is not #N', async () => { 357 + const pullUri1 = 'at://did:plc:test123/sh.tangled.repo.pull/rkey1'; 358 + const pullUri2 = 'at://did:plc:test123/sh.tangled.repo.pull/rkey2'; 359 + 360 + vi.mocked(getBacklinks).mockResolvedValue({ 361 + total: 2, 362 + records: [ 363 + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull', rkey: 'rkey1' }, 364 + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull', rkey: 'rkey2' }, 365 + ], 366 + cursor: null, 367 + }); 368 + 369 + const mockGetRecord = vi 370 + .fn() 371 + .mockResolvedValueOnce({ 372 + data: { 373 + value: { 374 + $type: 'sh.tangled.repo.pull', 375 + target: { repo: REPO_AT_URI, branch: 'main' }, 376 + title: 'First', 377 + patchBlob: {}, 378 + createdAt: '2024-01-01T00:00:00.000Z', 379 + }, 380 + uri: pullUri1, 381 + cid: 'cid1', 382 + }, 383 + }) 384 + .mockResolvedValueOnce({ 385 + data: { 386 + value: { 387 + $type: 'sh.tangled.repo.pull', 388 + target: { repo: REPO_AT_URI, branch: 'main' }, 389 + title: 'Second', 390 + patchBlob: {}, 391 + createdAt: '2024-01-02T00:00:00.000Z', 392 + }, 393 + uri: pullUri2, 394 + cid: 'cid2', 395 + }, 396 + }); 397 + 398 + vi.mocked(mockClient.getAgent).mockReturnValue({ 399 + com: { atproto: { repo: { getRecord: mockGetRecord } } }, 400 + } as never); 401 + 402 + const num = await resolveSequentialPullNumber('rkey2', pullUri2, mockClient, REPO_AT_URI); 403 + expect(num).toBe(2); 404 + }); 405 + });