Rust CLI for tangled

Implement PR merge, remove unused knot commands

- Add merge_pull() to API client using sh.tangled.repo.merge
- Implement 'pr merge' CLI command with ServiceAuth flow
- Remove stub knot commands (list, add, verify, set-default, remove)
- Keep only 'knot migrate' which is fully implemented

Changed files
+120 -83
crates
tangled-api
src
tangled-cli
src
commands
+54 -1
crates/tangled-api/src/client.rs
··· 49 49 Ok(res.json::<TRes>().await?) 50 50 } 51 51 52 - async fn get_json<TRes: DeserializeOwned>( 52 + pub async fn get_json<TRes: DeserializeOwned>( 53 53 &self, 54 54 method: &str, 55 55 params: &[(&str, String)], ··· 1121 1121 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1122 1122 .await?; 1123 1123 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri")) 1124 + } 1125 + 1126 + pub async fn merge_pull( 1127 + &self, 1128 + pull_did: &str, 1129 + pull_rkey: &str, 1130 + repo_did: &str, 1131 + repo_name: &str, 1132 + pds_base: &str, 1133 + access_jwt: &str, 1134 + ) -> Result<()> { 1135 + // Fetch the pull request to get patch and target branch 1136 + let pds_client = TangledClient::new(pds_base); 1137 + let pull = pds_client 1138 + .get_pull_record(pull_did, pull_rkey, Some(access_jwt)) 1139 + .await?; 1140 + 1141 + // Get service auth token for the knot 1142 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 1143 + 1144 + #[derive(Serialize)] 1145 + struct MergeReq<'a> { 1146 + did: &'a str, 1147 + name: &'a str, 1148 + patch: &'a str, 1149 + branch: &'a str, 1150 + #[serde(skip_serializing_if = "Option::is_none")] 1151 + #[serde(rename = "commitMessage")] 1152 + commit_message: Option<&'a str>, 1153 + #[serde(skip_serializing_if = "Option::is_none")] 1154 + #[serde(rename = "commitBody")] 1155 + commit_body: Option<&'a str>, 1156 + } 1157 + 1158 + let commit_body = if pull.body.is_empty() { 1159 + None 1160 + } else { 1161 + Some(pull.body.as_str()) 1162 + }; 1163 + 1164 + let req = MergeReq { 1165 + did: repo_did, 1166 + name: repo_name, 1167 + patch: &pull.patch, 1168 + branch: &pull.target.branch, 1169 + commit_message: Some(&pull.title), 1170 + commit_body, 1171 + }; 1172 + 1173 + let _: serde_json::Value = self 1174 + .post_json("sh.tangled.repo.merge", &req, Some(&sa)) 1175 + .await?; 1176 + Ok(()) 1124 1177 } 1125 1178 } 1126 1179
-40
crates/tangled-cli/src/cli.rs
··· 284 284 #[derive(Args, Debug, Clone)] 285 285 pub struct PrMergeArgs { 286 286 pub id: String, 287 - #[arg(long, default_value_t = false)] 288 - pub squash: bool, 289 - #[arg(long, default_value_t = false)] 290 - pub rebase: bool, 291 - #[arg(long, default_value_t = false)] 292 - pub no_ff: bool, 293 287 } 294 288 295 289 #[derive(Subcommand, Debug, Clone)] 296 290 pub enum KnotCommand { 297 - List(KnotListArgs), 298 - Add(KnotAddArgs), 299 - Verify(KnotVerifyArgs), 300 - SetDefault(KnotRefArgs), 301 - Remove(KnotRefArgs), 302 291 /// Migrate a repository to another knot 303 292 Migrate(KnotMigrateArgs), 304 - } 305 - 306 - #[derive(Args, Debug, Clone)] 307 - pub struct KnotListArgs { 308 - #[arg(long, default_value_t = false)] 309 - pub public: bool, 310 - #[arg(long, default_value_t = false)] 311 - pub owned: bool, 312 - } 313 - 314 - #[derive(Args, Debug, Clone)] 315 - pub struct KnotAddArgs { 316 - pub url: String, 317 - #[arg(long)] 318 - pub did: Option<String>, 319 - #[arg(long)] 320 - pub name: Option<String>, 321 - #[arg(long, default_value_t = false)] 322 - pub verify: bool, 323 - } 324 - 325 - #[derive(Args, Debug, Clone)] 326 - pub struct KnotVerifyArgs { 327 - pub url: String, 328 - } 329 - 330 - #[derive(Args, Debug, Clone)] 331 - pub struct KnotRefArgs { 332 - pub url: String, 333 293 } 334 294 335 295 #[derive(Args, Debug, Clone)]
+1 -39
crates/tangled-cli/src/commands/knot.rs
··· 1 - use crate::cli::{ 2 - Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotMigrateArgs, KnotRefArgs, KnotVerifyArgs, 3 - }; 1 + use crate::cli::{Cli, KnotCommand, KnotMigrateArgs}; 4 2 use anyhow::anyhow; 5 3 use anyhow::Result; 6 4 use git2::{Direction, Repository as GitRepository, StatusOptions}; ··· 9 7 10 8 pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> { 11 9 match cmd { 12 - KnotCommand::List(args) => list(args).await, 13 - KnotCommand::Add(args) => add(args).await, 14 - KnotCommand::Verify(args) => verify(args).await, 15 - KnotCommand::SetDefault(args) => set_default(args).await, 16 - KnotCommand::Remove(args) => remove(args).await, 17 10 KnotCommand::Migrate(args) => migrate(args).await, 18 11 } 19 - } 20 - 21 - async fn list(args: KnotListArgs) -> Result<()> { 22 - println!( 23 - "Knot list (stub) public={} owned={}", 24 - args.public, args.owned 25 - ); 26 - Ok(()) 27 - } 28 - 29 - async fn add(args: KnotAddArgs) -> Result<()> { 30 - println!( 31 - "Knot add (stub) url={} did={:?} name={:?} verify={}", 32 - args.url, args.did, args.name, args.verify 33 - ); 34 - Ok(()) 35 - } 36 - 37 - async fn verify(args: KnotVerifyArgs) -> Result<()> { 38 - println!("Knot verify (stub) url={}", args.url); 39 - Ok(()) 40 - } 41 - 42 - async fn set_default(args: KnotRefArgs) -> Result<()> { 43 - println!("Knot set-default (stub) url={}", args.url); 44 - Ok(()) 45 - } 46 - 47 - async fn remove(args: KnotRefArgs) -> Result<()> { 48 - println!("Knot remove (stub) url={}", args.url); 49 - Ok(()) 50 12 } 51 13 52 14 async fn migrate(args: KnotMigrateArgs) -> Result<()> {
+65 -3
crates/tangled-cli/src/commands/pr.rs
··· 183 183 Ok(()) 184 184 } 185 185 186 - async fn merge(_args: PrMergeArgs) -> Result<()> { 187 - // Placeholder: merging requires server-side merge call with the patch and target branch. 188 - println!("Merge via CLI is not implemented yet. Use the web UI for now."); 186 + async fn merge(args: PrMergeArgs) -> Result<()> { 187 + let mgr = SessionManager::default(); 188 + let session = mgr 189 + .load()? 190 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 191 + let (did, rkey) = parse_record_id(&args.id, &session.did)?; 192 + let pds = session 193 + .pds 194 + .clone() 195 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 196 + .unwrap_or_else(|| "https://bsky.social".into()); 197 + 198 + // Get the PR to find the target repo 199 + let pds_client = tangled_api::TangledClient::new(&pds); 200 + let pull = pds_client 201 + .get_pull_record(&did, &rkey, Some(session.access_jwt.as_str())) 202 + .await?; 203 + 204 + // Parse the target repo AT-URI to get did and name 205 + let target_repo = &pull.target.repo; 206 + // Format: at://did:plc:.../sh.tangled.repo/rkey 207 + let parts: Vec<&str> = target_repo.strip_prefix("at://").unwrap_or(target_repo).split('/').collect(); 208 + if parts.len() < 2 { 209 + return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 210 + } 211 + let repo_did = parts[0]; 212 + 213 + // Get repo info to find the name 214 + // Parse rkey from target repo AT-URI 215 + let repo_rkey = if parts.len() >= 4 { 216 + parts[3] 217 + } else { 218 + return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 219 + }; 220 + 221 + #[derive(serde::Deserialize)] 222 + struct Rec { 223 + name: String, 224 + } 225 + #[derive(serde::Deserialize)] 226 + struct GetRes { 227 + value: Rec, 228 + } 229 + let params = [ 230 + ("repo", repo_did.to_string()), 231 + ("collection", "sh.tangled.repo".to_string()), 232 + ("rkey", repo_rkey.to_string()), 233 + ]; 234 + let repo_rec: GetRes = pds_client 235 + .get_json("com.atproto.repo.getRecord", &params, Some(session.access_jwt.as_str())) 236 + .await?; 237 + 238 + // Call merge on the default Tangled API base (tngl.sh) 239 + let api = tangled_api::TangledClient::default(); 240 + api.merge_pull( 241 + &did, 242 + &rkey, 243 + repo_did, 244 + &repo_rec.value.name, 245 + &pds, 246 + &session.access_jwt, 247 + ) 248 + .await?; 249 + 250 + println!("Merged PR {}:{}", did, rkey); 189 251 Ok(()) 190 252 } 191 253