+55
crates/tangled-api/src/client.rs
+55
crates/tangled-api/src/client.rs
···
1237
1237
Ok(())
1238
1238
}
1239
1239
1240
+
pub async fn merge_check(
1241
+
&self,
1242
+
repo_did: &str,
1243
+
repo_name: &str,
1244
+
branch: &str,
1245
+
patch: &str,
1246
+
pds_base: &str,
1247
+
access_jwt: &str,
1248
+
) -> Result<MergeCheckResponse> {
1249
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
1250
+
1251
+
let req = MergeCheckRequest {
1252
+
did: repo_did.to_string(),
1253
+
name: repo_name.to_string(),
1254
+
branch: branch.to_string(),
1255
+
patch: patch.to_string(),
1256
+
};
1257
+
1258
+
self.post_json("sh.tangled.repo.mergeCheck", &req, Some(&sa))
1259
+
.await
1260
+
}
1261
+
1240
1262
pub async fn update_repo_spindle(
1241
1263
&self,
1242
1264
did: &str,
···
1373
1395
pub patch: String,
1374
1396
#[serde(rename = "createdAt")]
1375
1397
pub created_at: String,
1398
+
// Stack support fields
1399
+
#[serde(skip_serializing_if = "Option::is_none")]
1400
+
pub stack_id: Option<String>,
1401
+
#[serde(skip_serializing_if = "Option::is_none")]
1402
+
pub change_id: Option<String>,
1403
+
#[serde(skip_serializing_if = "Option::is_none")]
1404
+
pub parent_change_id: Option<String>,
1376
1405
}
1377
1406
1378
1407
#[derive(Debug, Clone)]
···
1382
1411
pub pull: Pull,
1383
1412
}
1384
1413
1414
+
// Merge check types for stacked diff conflict detection
1415
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1416
+
pub struct MergeCheckRequest {
1417
+
pub did: String,
1418
+
pub name: String,
1419
+
pub branch: String,
1420
+
pub patch: String,
1421
+
}
1422
+
1423
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1424
+
pub struct MergeCheckResponse {
1425
+
pub is_conflicted: bool,
1426
+
#[serde(default)]
1427
+
pub conflicts: Vec<ConflictInfo>,
1428
+
#[serde(skip_serializing_if = "Option::is_none")]
1429
+
pub message: Option<String>,
1430
+
#[serde(skip_serializing_if = "Option::is_none")]
1431
+
pub error: Option<String>,
1432
+
}
1433
+
1434
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1435
+
pub struct ConflictInfo {
1436
+
pub filename: String,
1437
+
pub reason: String,
1438
+
}
1439
+
1385
1440
#[derive(Debug, Clone)]
1386
1441
pub struct RepoRecord {
1387
1442
pub did: String,
+2
-2
crates/tangled-api/src/lib.rs
+2
-2
crates/tangled-api/src/lib.rs
···
2
2
3
3
pub use client::TangledClient;
4
4
pub use client::{
5
-
CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, Pull, PullRecord,
6
-
RepoRecord, Repository, Secret,
5
+
ConflictInfo, CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages,
6
+
MergeCheckRequest, MergeCheckResponse, Pull, PullRecord, RepoRecord, Repository, Secret,
7
7
};
+336
-44
crates/tangled-cli/src/commands/pr.rs
+336
-44
crates/tangled-cli/src/commands/pr.rs
···
179
179
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
180
180
.unwrap_or_else(|| "https://bsky.social".into());
181
181
182
-
// Get the PR to find the target repo
182
+
// Get the PR
183
183
let pds_client = tangled_api::TangledClient::new(&pds);
184
184
let pull = pds_client
185
185
.get_pull_record(&did, &rkey, Some(session.access_jwt.as_str()))
186
186
.await?;
187
187
188
-
// Parse the target repo AT-URI to get did and name
189
-
let target_repo = &pull.target.repo;
190
-
// Format: at://did:plc:.../sh.tangled.repo/rkey
191
-
let parts: Vec<&str> = target_repo.strip_prefix("at://").unwrap_or(target_repo).split('/').collect();
192
-
if parts.len() < 2 {
193
-
return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo));
194
-
}
195
-
let repo_did = parts[0];
188
+
// Parse target repo info
189
+
let (repo_did, repo_name) = parse_target_repo_info(&pull, &pds_client, &session).await?;
196
190
197
-
// Get repo info to find the name
198
-
// Parse rkey from target repo AT-URI
199
-
let repo_rkey = if parts.len() >= 4 {
200
-
parts[3]
191
+
// Check if PR is part of a stack
192
+
if let Some(stack_id) = &pull.stack_id {
193
+
merge_stacked_pr(
194
+
&pds_client,
195
+
&session,
196
+
&pull,
197
+
&did,
198
+
&rkey,
199
+
&repo_did,
200
+
&repo_name,
201
+
stack_id,
202
+
&pds,
203
+
)
204
+
.await?;
201
205
} else {
202
-
return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo));
203
-
};
204
-
205
-
#[derive(serde::Deserialize)]
206
-
struct Rec {
207
-
name: String,
206
+
// Single PR merge (existing logic)
207
+
merge_single_pr(&session, &did, &rkey, &repo_did, &repo_name, &pds).await?;
208
208
}
209
-
#[derive(serde::Deserialize)]
210
-
struct GetRes {
211
-
value: Rec,
212
-
}
213
-
let params = [
214
-
("repo", repo_did.to_string()),
215
-
("collection", "sh.tangled.repo".to_string()),
216
-
("rkey", repo_rkey.to_string()),
217
-
];
218
-
let repo_rec: GetRes = pds_client
219
-
.get_json("com.atproto.repo.getRecord", ¶ms, Some(session.access_jwt.as_str()))
220
-
.await?;
221
-
222
-
// Call merge on the default Tangled API base (tngl.sh)
223
-
let api = tangled_api::TangledClient::default();
224
-
api.merge_pull(
225
-
&did,
226
-
&rkey,
227
-
repo_did,
228
-
&repo_rec.value.name,
229
-
&pds,
230
-
&session.access_jwt,
231
-
)
232
-
.await?;
233
209
234
-
println!("Merged PR {}:{}", did, rkey);
235
210
Ok(())
236
211
}
237
212
···
259
234
}
260
235
Ok((default_did.to_string(), id.to_string()))
261
236
}
237
+
238
+
// Helper functions for stacked PR merge support
239
+
240
+
async fn merge_single_pr(
241
+
session: &tangled_config::session::Session,
242
+
did: &str,
243
+
rkey: &str,
244
+
repo_did: &str,
245
+
repo_name: &str,
246
+
pds: &str,
247
+
) -> Result<()> {
248
+
let api = tangled_api::TangledClient::default();
249
+
api.merge_pull(did, rkey, repo_did, repo_name, pds, &session.access_jwt)
250
+
.await?;
251
+
252
+
println!("Merged PR {}:{}", did, rkey);
253
+
Ok(())
254
+
}
255
+
256
+
async fn merge_stacked_pr(
257
+
pds_client: &tangled_api::TangledClient,
258
+
session: &tangled_config::session::Session,
259
+
current_pull: &tangled_api::Pull,
260
+
current_did: &str,
261
+
current_rkey: &str,
262
+
repo_did: &str,
263
+
repo_name: &str,
264
+
stack_id: &str,
265
+
pds: &str,
266
+
) -> Result<()> {
267
+
// Step 1: Get full stack
268
+
println!("๐ Detecting stack...");
269
+
let stack = get_stack_pulls(pds_client, &session.did, stack_id, &session.access_jwt).await?;
270
+
271
+
if stack.is_empty() {
272
+
return Err(anyhow!("Stack is empty"));
273
+
}
274
+
275
+
// Step 2: Find substack (current PR and all below it)
276
+
let substack = find_substack(&stack, current_pull.change_id.as_deref())?;
277
+
278
+
println!(
279
+
"โ Detected PR is part of stack (stack has {} total PRs)",
280
+
stack.len()
281
+
);
282
+
println!();
283
+
println!("The following {} PR(s) will be merged:", substack.len());
284
+
285
+
for (idx, pr) in substack.iter().enumerate() {
286
+
let marker = if pr.rkey == current_rkey {
287
+
" (current)"
288
+
} else {
289
+
""
290
+
};
291
+
println!(" [{}] {}: {}{}", idx + 1, pr.rkey, pr.pull.title, marker);
292
+
}
293
+
println!();
294
+
295
+
// Step 3: Check for conflicts
296
+
println!("โ Checking for conflicts...");
297
+
let api = tangled_api::TangledClient::default();
298
+
let conflicts = check_stack_conflicts(
299
+
&api,
300
+
repo_did,
301
+
repo_name,
302
+
¤t_pull.target.branch,
303
+
&substack,
304
+
pds,
305
+
&session.access_jwt,
306
+
)
307
+
.await?;
308
+
309
+
if !conflicts.is_empty() {
310
+
println!("โ Cannot merge: conflicts detected");
311
+
println!();
312
+
for (pr_rkey, conflict_resp) in conflicts {
313
+
println!(
314
+
" PR {}: Conflicts in {} file(s)",
315
+
pr_rkey,
316
+
conflict_resp.conflicts.len()
317
+
);
318
+
for conflict in conflict_resp.conflicts {
319
+
println!(" - {}: {}", conflict.filename, conflict.reason);
320
+
}
321
+
}
322
+
return Err(anyhow!("Stack has merge conflicts"));
323
+
}
324
+
325
+
println!("โ All PRs can be merged cleanly");
326
+
println!();
327
+
328
+
// Step 4: Confirmation prompt
329
+
if !prompt_confirmation(&format!("Merge {} pull request(s)?", substack.len()))? {
330
+
println!("Merge cancelled.");
331
+
return Ok(());
332
+
}
333
+
334
+
// Step 5: Merge the stack (backend handles combined patch)
335
+
println!("Merging {} PR(s)...", substack.len());
336
+
337
+
// Use the current PR's merge endpoint - backend will handle the stack
338
+
api.merge_pull(
339
+
current_did,
340
+
current_rkey,
341
+
repo_did,
342
+
repo_name,
343
+
pds,
344
+
&session.access_jwt,
345
+
)
346
+
.await?;
347
+
348
+
println!("โ Successfully merged {} pull request(s)", substack.len());
349
+
350
+
Ok(())
351
+
}
352
+
353
+
async fn get_stack_pulls(
354
+
client: &tangled_api::TangledClient,
355
+
user_did: &str,
356
+
stack_id: &str,
357
+
bearer: &str,
358
+
) -> Result<Vec<tangled_api::PullRecord>> {
359
+
// List all user's PRs and filter by stack_id
360
+
let all_pulls = client.list_pulls(user_did, None, Some(bearer)).await?;
361
+
362
+
let mut stack_pulls: Vec<_> = all_pulls
363
+
.into_iter()
364
+
.filter(|p| p.pull.stack_id.as_deref() == Some(stack_id))
365
+
.collect();
366
+
367
+
// Order by parent relationships (top to bottom)
368
+
order_stack(&mut stack_pulls)?;
369
+
370
+
Ok(stack_pulls)
371
+
}
372
+
373
+
fn order_stack(pulls: &mut Vec<tangled_api::PullRecord>) -> Result<()> {
374
+
if pulls.is_empty() {
375
+
return Ok(());
376
+
}
377
+
378
+
// Build parent map: parent_change_id -> pull
379
+
let mut change_id_map: std::collections::HashMap<String, usize> =
380
+
std::collections::HashMap::new();
381
+
let mut parent_map: std::collections::HashMap<String, usize> =
382
+
std::collections::HashMap::new();
383
+
384
+
for (idx, pr) in pulls.iter().enumerate() {
385
+
if let Some(cid) = &pr.pull.change_id {
386
+
change_id_map.insert(cid.clone(), idx);
387
+
}
388
+
if let Some(pcid) = &pr.pull.parent_change_id {
389
+
parent_map.insert(pcid.clone(), idx);
390
+
}
391
+
}
392
+
393
+
// Find top of stack (not a parent of any other PR)
394
+
let mut top_idx = None;
395
+
for (idx, pr) in pulls.iter().enumerate() {
396
+
if let Some(cid) = &pr.pull.change_id {
397
+
if !parent_map.contains_key(cid) {
398
+
top_idx = Some(idx);
399
+
break;
400
+
}
401
+
}
402
+
}
403
+
404
+
let top_idx = top_idx.ok_or_else(|| anyhow!("Could not find top of stack"))?;
405
+
406
+
// Walk down the stack to build ordered list
407
+
let mut ordered = Vec::new();
408
+
let mut current_idx = top_idx;
409
+
let mut visited = std::collections::HashSet::new();
410
+
411
+
loop {
412
+
if visited.contains(¤t_idx) {
413
+
return Err(anyhow!("Circular dependency in stack"));
414
+
}
415
+
visited.insert(current_idx);
416
+
ordered.push(current_idx);
417
+
418
+
// Find child (PR that has this PR as parent)
419
+
let current_parent = &pulls[current_idx].pull.parent_change_id;
420
+
if current_parent.is_none() {
421
+
break;
422
+
}
423
+
424
+
let next_idx = change_id_map.get(current_parent.as_ref().unwrap());
425
+
426
+
if let Some(&next) = next_idx {
427
+
current_idx = next;
428
+
} else {
429
+
break;
430
+
}
431
+
}
432
+
433
+
// Reorder pulls based on ordered indices
434
+
let original = pulls.clone();
435
+
pulls.clear();
436
+
for idx in ordered {
437
+
pulls.push(original[idx].clone());
438
+
}
439
+
440
+
Ok(())
441
+
}
442
+
443
+
fn find_substack<'a>(
444
+
stack: &'a [tangled_api::PullRecord],
445
+
current_change_id: Option<&str>,
446
+
) -> Result<Vec<&'a tangled_api::PullRecord>> {
447
+
let change_id = current_change_id.ok_or_else(|| anyhow!("PR has no change_id"))?;
448
+
449
+
let position = stack
450
+
.iter()
451
+
.position(|p| p.pull.change_id.as_deref() == Some(change_id))
452
+
.ok_or_else(|| anyhow!("PR not found in stack"))?;
453
+
454
+
// Return from current position to end (including current)
455
+
Ok(stack[position..].iter().collect())
456
+
}
457
+
458
+
async fn check_stack_conflicts(
459
+
api: &tangled_api::TangledClient,
460
+
repo_did: &str,
461
+
repo_name: &str,
462
+
target_branch: &str,
463
+
substack: &[&tangled_api::PullRecord],
464
+
pds: &str,
465
+
access_jwt: &str,
466
+
) -> Result<Vec<(String, tangled_api::MergeCheckResponse)>> {
467
+
let mut conflicts = Vec::new();
468
+
let mut cumulative_patch = String::new();
469
+
470
+
// Check each PR in order (bottom to top of substack)
471
+
for pr in substack.iter().rev() {
472
+
cumulative_patch.push_str(&pr.pull.patch);
473
+
cumulative_patch.push('\n');
474
+
475
+
let check = api
476
+
.merge_check(
477
+
repo_did,
478
+
repo_name,
479
+
target_branch,
480
+
&cumulative_patch,
481
+
pds,
482
+
access_jwt,
483
+
)
484
+
.await?;
485
+
486
+
if check.is_conflicted {
487
+
conflicts.push((pr.rkey.clone(), check));
488
+
}
489
+
}
490
+
491
+
Ok(conflicts)
492
+
}
493
+
494
+
fn prompt_confirmation(message: &str) -> Result<bool> {
495
+
use std::io::{self, Write};
496
+
497
+
print!("{} [y/N]: ", message);
498
+
io::stdout().flush()?;
499
+
500
+
let mut input = String::new();
501
+
io::stdin().read_line(&mut input)?;
502
+
503
+
Ok(matches!(
504
+
input.trim().to_lowercase().as_str(),
505
+
"y" | "yes"
506
+
))
507
+
}
508
+
509
+
async fn parse_target_repo_info(
510
+
pull: &tangled_api::Pull,
511
+
pds_client: &tangled_api::TangledClient,
512
+
session: &tangled_config::session::Session,
513
+
) -> Result<(String, String)> {
514
+
let target_repo = &pull.target.repo;
515
+
let parts: Vec<&str> = target_repo
516
+
.strip_prefix("at://")
517
+
.unwrap_or(target_repo)
518
+
.split('/')
519
+
.collect();
520
+
521
+
if parts.len() < 4 {
522
+
return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo));
523
+
}
524
+
525
+
let repo_did = parts[0].to_string();
526
+
let repo_rkey = parts[3];
527
+
528
+
// Get repo name
529
+
#[derive(serde::Deserialize)]
530
+
struct Rec {
531
+
name: String,
532
+
}
533
+
#[derive(serde::Deserialize)]
534
+
struct GetRes {
535
+
value: Rec,
536
+
}
537
+
538
+
let params = [
539
+
("repo", repo_did.clone()),
540
+
("collection", "sh.tangled.repo".to_string()),
541
+
("rkey", repo_rkey.to_string()),
542
+
];
543
+
544
+
let repo_rec: GetRes = pds_client
545
+
.get_json(
546
+
"com.atproto.repo.getRecord",
547
+
¶ms,
548
+
Some(&session.access_jwt),
549
+
)
550
+
.await?;
551
+
552
+
Ok((repo_did, repo_rec.value.name))
553
+
}