Rust CLI for tangled

Fix AT-URI parsing and knot routing in PR merge

Three bugs fixed:
1. parse_target_repo_info and parse_record_id expected 4 parts in a split
AT-URI but valid AT-URIs only have 3 parts after stripping the at://
prefix (in both pr.rs and issue.rs).
2. PR merge sent requests to the default tngl.sh gateway instead of the
actual knot hosting the repo, causing service auth token verification
to fail.
3. merge_pull used post_json but the merge endpoint returns an empty body
on success, causing a JSON parse error.

+23 -15
+2 -4
crates/tangled-api/src/client.rs
··· 1231 1231 commit_body, 1232 1232 }; 1233 1233 1234 - let _: serde_json::Value = self 1235 - .post_json("sh.tangled.repo.merge", &req, Some(&sa)) 1236 - .await?; 1237 - Ok(()) 1234 + self.post("sh.tangled.repo.merge", &req, Some(&sa)) 1235 + .await 1238 1236 } 1239 1237 1240 1238 pub async fn merge_check(
+2 -2
crates/tangled-cli/src/commands/issue.rs
··· 208 208 fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> { 209 209 if let Some(rest) = id.strip_prefix("at://") { 210 210 let parts: Vec<&str> = rest.split('/').collect(); 211 - if parts.len() >= 4 { 212 - return Ok((parts[0].to_string(), parts[3].to_string())); 211 + if parts.len() >= 3 { 212 + return Ok((parts[0].to_string(), parts[2].to_string())); 213 213 } 214 214 } 215 215 if let Some((did, rkey)) = id.split_once(':') {
+19 -9
crates/tangled-cli/src/commands/pr.rs
··· 187 187 .await?; 188 188 189 189 // Parse target repo info 190 - let (repo_did, repo_name) = parse_target_repo_info(&pull, &pds_client, &session).await?; 190 + let (repo_did, repo_name, knot_host) = parse_target_repo_info(&pull, &pds_client, &session).await?; 191 191 192 192 // Check if PR is part of a stack 193 193 if let Some(stack_id) = &pull.stack_id { ··· 201 201 &repo_name, 202 202 stack_id, 203 203 &pds, 204 + &knot_host, 204 205 ) 205 206 .await?; 206 207 } else { 207 208 // Single PR merge (existing logic) 208 - merge_single_pr(&session, &did, &rkey, &repo_did, &repo_name, &pds).await?; 209 + merge_single_pr(&session, &did, &rkey, &repo_did, &repo_name, &pds, &knot_host).await?; 209 210 } 210 211 211 212 Ok(()) ··· 214 215 fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> { 215 216 if let Some(rest) = id.strip_prefix("at://") { 216 217 let parts: Vec<&str> = rest.split('/').collect(); 217 - if parts.len() >= 4 { 218 - return Ok((parts[0].to_string(), parts[3].to_string())); 218 + if parts.len() >= 3 { 219 + return Ok((parts[0].to_string(), parts[2].to_string())); 219 220 } 220 221 } 221 222 if let Some((did, rkey)) = id.split_once(':') { ··· 233 234 repo_did: &str, 234 235 repo_name: &str, 235 236 pds: &str, 237 + knot_host: &str, 236 238 ) -> Result<()> { 237 - let api = tangled_api::TangledClient::default(); 239 + let api = tangled_api::TangledClient::new(knot_host); 238 240 api.merge_pull(did, rkey, repo_did, repo_name, pds, &session.access_jwt) 239 241 .await?; 240 242 ··· 252 254 repo_name: &str, 253 255 stack_id: &str, 254 256 pds: &str, 257 + knot_host: &str, 255 258 ) -> Result<()> { 256 259 // Step 1: Get full stack 257 260 println!("🔍 Detecting stack..."); ··· 283 286 284 287 // Step 3: Check for conflicts 285 288 println!("✓ Checking for conflicts..."); 286 - let api = tangled_api::TangledClient::default(); 289 + let api = tangled_api::TangledClient::new(knot_host); 287 290 let conflicts = check_stack_conflicts( 288 291 &api, 289 292 repo_did, ··· 499 502 pull: &tangled_api::Pull, 500 503 pds_client: &tangled_api::TangledClient, 501 504 session: &tangled_config::session::Session, 502 - ) -> Result<(String, String)> { 505 + ) -> Result<(String, String, String)> { 503 506 let target_repo = &pull.target.repo; 504 507 let parts: Vec<&str> = target_repo 505 508 .strip_prefix("at://") ··· 514 517 let repo_did = parts[0].to_string(); 515 518 let repo_rkey = parts[2]; 516 519 517 - // Get repo name 520 + // Get repo name and knot 518 521 #[derive(serde::Deserialize)] 519 522 struct Rec { 520 523 name: String, 524 + #[serde(default)] 525 + knot: Option<String>, 521 526 } 522 527 #[derive(serde::Deserialize)] 523 528 struct GetRes { ··· 538 543 ) 539 544 .await?; 540 545 541 - Ok((repo_did, repo_rec.value.name)) 546 + let knot_host = match repo_rec.value.knot.as_deref() { 547 + Some(k) if !k.is_empty() => format!("https://{}", k), 548 + _ => "https://tngl.sh".to_string(), 549 + }; 550 + 551 + Ok((repo_did, repo_rec.value.name, knot_host)) 542 552 }