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