Rust CLI for tangled

Compare changes

Choose any two refs to compare.

Changed files
+151 -20
crates
tangled-api
src
tangled-cli
src
commands
+36
TODO.md
··· 1 + # TODO - Tech Debt 2 + 3 + ## Pull Request Support 4 + 5 + ### Branch-Based PR Merge 6 + - [ ] Implement branch-based PR merge support in CLI 7 + - **Issue**: Currently only patch-based PRs can be merged via `tangled pr merge` 8 + - **Location**: `crates/tangled-api/src/client.rs:1250-1253` 9 + - **Current behavior**: Returns error: "Cannot merge branch-based PR via CLI. Please use the web interface." 10 + - **Required**: Add support for merging PRs that have a `source` field with SHA/branch info instead of a `patch` field 11 + - **Related**: Server-side merge API may need updates to support branch merges 12 + 13 + ### PR Comments Display 14 + - [ ] Implement `--comments` flag functionality in `pr show` command 15 + - **Issue**: Flag is defined but not implemented 16 + - **Location**: `crates/tangled-cli/src/commands/pr.rs:145-180` 17 + - **Current behavior**: `tangled pr show <id> --comments` doesn't display any comments 18 + - **Required**: 19 + - Fetch comments from the API 20 + - Display comment author, timestamp, and content 21 + - Handle threaded/nested comments if supported 22 + - **API**: Need to determine correct endpoint for fetching PR comments 23 + 24 + ### PR Format Compatibility 25 + - [x] Support both patch-based and branch-based PR formats 26 + - **Completed**: Added `PullSource` struct and made `patch` field optional 27 + - **Location**: `crates/tangled-api/src/client.rs:1392-1413` 28 + - **Details**: PRs can now have either: 29 + - `patch: String` (legacy format) 30 + - `source: { sha, repo?, branch? }` (new format) 31 + 32 + ## Related Issues 33 + 34 + - Consider adding `--format json` output for programmatic access to PR data 35 + - Add better error messages when operations aren't supported for certain PR types 36 + - Document the differences between patch-based and branch-based PRs in user docs
+59 -2
crates/tangled-api/src/client.rs
··· 987 987 Ok(out) 988 988 } 989 989 990 + pub async fn list_repo_pulls( 991 + &self, 992 + repo_at: &str, 993 + state: Option<&str>, 994 + pds_base: &str, 995 + access_jwt: &str, 996 + ) -> Result<Vec<RepoPull>> { 997 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 998 + 999 + #[derive(Deserialize)] 1000 + struct Res { 1001 + pulls: Vec<RepoPull>, 1002 + } 1003 + 1004 + let mut params = vec![("repo", repo_at.to_string())]; 1005 + if let Some(s) = state { 1006 + params.push(("state", s.to_string())); 1007 + } 1008 + 1009 + let res: Res = self 1010 + .get_json("sh.tangled.repo.listPulls", &params, Some(&sa)) 1011 + .await?; 1012 + Ok(res.pulls) 1013 + } 1014 + 990 1015 #[allow(clippy::too_many_arguments)] 991 1016 pub async fn create_pull( 992 1017 &self, ··· 1222 1247 Some(pull.body.as_str()) 1223 1248 }; 1224 1249 1250 + // For now, only patch-based PRs can be merged via CLI 1251 + // Branch-based PRs need to be merged via the web interface 1252 + let patch_str = pull.patch.as_deref() 1253 + .ok_or_else(|| anyhow!("Cannot merge branch-based PR via CLI. Please use the web interface."))?; 1254 + 1225 1255 let req = MergeReq { 1226 1256 did: repo_did, 1227 1257 name: repo_name, 1228 - patch: &pull.patch, 1258 + patch: patch_str, 1229 1259 branch: &pull.target.branch, 1230 1260 commit_message: Some(&pull.title), 1231 1261 commit_body, ··· 1364 1394 pub branch: String, 1365 1395 } 1366 1396 1397 + #[derive(Debug, Clone, Serialize, Deserialize)] 1398 + pub struct PullSource { 1399 + pub sha: String, 1400 + #[serde(default)] 1401 + pub repo: Option<String>, 1402 + #[serde(default)] 1403 + pub branch: Option<String>, 1404 + } 1405 + 1367 1406 #[derive(Debug, Clone, Serialize, Deserialize)] 1368 1407 pub struct Pull { 1369 1408 pub target: PullTarget, 1370 1409 pub title: String, 1371 1410 #[serde(default)] 1372 1411 pub body: String, 1373 - pub patch: String, 1412 + #[serde(default)] 1413 + pub patch: Option<String>, 1414 + #[serde(default)] 1415 + pub source: Option<PullSource>, 1374 1416 #[serde(rename = "createdAt")] 1375 1417 pub created_at: String, 1376 1418 } ··· 1382 1424 pub pull: Pull, 1383 1425 } 1384 1426 1427 + #[derive(Debug, Clone, Deserialize)] 1428 + pub struct RepoPull { 1429 + pub rkey: String, 1430 + #[serde(rename = "ownerDid")] 1431 + pub owner_did: String, 1432 + #[serde(rename = "pullId")] 1433 + pub pull_id: i32, 1434 + pub title: String, 1435 + pub state: i32, 1436 + #[serde(rename = "targetBranch")] 1437 + pub target_branch: String, 1438 + #[serde(rename = "createdAt")] 1439 + pub created_at: String, 1440 + } 1441 + 1385 1442 #[derive(Debug, Clone)] 1386 1443 pub struct RepoRecord { 1387 1444 pub did: String,
+56 -18
crates/tangled-cli/src/commands/pr.rs
··· 21 21 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 22 22 .unwrap_or_else(|| "https://bsky.social".into()); 23 23 let client = tangled_api::TangledClient::new(&pds); 24 - let target_repo_at = if let Some(repo) = &args.repo { 24 + 25 + // NEW: If --repo is specified, use the new API to list all PRs for that repo 26 + if let Some(repo) = &args.repo { 25 27 let (owner, name) = parse_repo_ref(repo, &session.handle); 26 28 let info = client 27 29 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 28 30 .await?; 29 - Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey)) 30 - } else { 31 - None 32 - }; 33 - let pulls = client 34 - .list_pulls( 35 - &session.did, 36 - target_repo_at.as_deref(), 37 - Some(session.access_jwt.as_str()), 38 - ) 39 - .await?; 40 - if pulls.is_empty() { 41 - println!("No pull requests found (showing only those you created)"); 31 + let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 32 + 33 + // Use Tangled API (tngl.sh) instead of PDS for aggregated query 34 + let api_client = tangled_api::TangledClient::default(); 35 + let state = args.state.as_deref(); 36 + let pulls = api_client 37 + .list_repo_pulls(&repo_at, state, &pds, &session.access_jwt) 38 + .await?; 39 + 40 + if pulls.is_empty() { 41 + println!("No pull requests found for this repository"); 42 + } else { 43 + println!("OWNER\tID\tTITLE\tSTATE"); 44 + for pr in pulls { 45 + let state_str = match pr.state { 46 + 1 => "open", 47 + 0 => "closed", 48 + 2 => "merged", 49 + _ => "unknown", 50 + }; 51 + println!("{}\t{}\t{}\t{}", pr.owner_did, pr.pull_id, pr.title, state_str); 52 + } 53 + } 42 54 } else { 43 - println!("RKEY\tTITLE\tTARGET"); 44 - for pr in pulls { 45 - println!("{}\t{}\t{}", pr.rkey, pr.pull.title, pr.pull.target.repo); 55 + // OLD: Without --repo, show only user's PRs (existing behavior) 56 + let pulls = client 57 + .list_pulls( 58 + &session.did, 59 + None, 60 + Some(session.access_jwt.as_str()), 61 + ) 62 + .await?; 63 + if pulls.is_empty() { 64 + println!("No pull requests found (showing only those you created)"); 65 + } else { 66 + println!("RKEY\tTITLE\tTARGET"); 67 + for pr in pulls { 68 + println!("{}\t{}\t{}", pr.rkey, pr.pull.title, pr.pull.target.repo); 69 + } 46 70 } 47 71 } 48 72 Ok(()) ··· 135 159 println!("BODY:\n{}", pr.body); 136 160 } 137 161 println!("TARGET: {} @ {}", pr.target.repo, pr.target.branch); 162 + 163 + // Display source info if it's a branch-based PR 164 + if let Some(source) = &pr.source { 165 + println!("SOURCE: {} ({})", source.sha, 166 + source.branch.as_deref().unwrap_or("detached")); 167 + if let Some(repo) = &source.repo { 168 + println!("SOURCE REPO: {}", repo); 169 + } 170 + } 171 + 138 172 if args.diff { 139 - println!("PATCH:\n{}", pr.patch); 173 + if let Some(patch) = &pr.patch { 174 + println!("PATCH:\n{}", patch); 175 + } else { 176 + println!("(No patch available - this is a branch-based PR)"); 177 + } 140 178 } 141 179 Ok(()) 142 180 }