Rust CLI for tangled

Add support for listing all PRs targeting a repository

Implements client-side support for the new listPulls XRPC endpoint that
allows querying all PRs (from any author) targeting a specific repository.

Changes:
- Add list_repo_pulls() method to query PRs for a specific repo
- Add RepoPull struct for repo PR listing response
- Update pr list command to support --repo flag for listing all PRs
- Add PullSource struct to support branch-based PRs
- Make Pull.patch optional for compatibility with branch-based PRs
- Prevent merging branch-based PRs via CLI (requires web interface)
- Add TODO.md to track remaining technical debt

The pr list command now supports two modes:
- Without --repo: shows only PRs created by the authenticated user (legacy)
- With --repo: shows all PRs targeting the specified repository (new)

vitorpy.com 05d2b17d 2950b681

verified
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 Ok(out) 988 } 989 990 #[allow(clippy::too_many_arguments)] 991 pub async fn create_pull( 992 &self, ··· 1222 Some(pull.body.as_str()) 1223 }; 1224 1225 let req = MergeReq { 1226 did: repo_did, 1227 name: repo_name, 1228 - patch: &pull.patch, 1229 branch: &pull.target.branch, 1230 commit_message: Some(&pull.title), 1231 commit_body, ··· 1365 } 1366 1367 #[derive(Debug, Clone, Serialize, Deserialize)] 1368 pub struct Pull { 1369 pub target: PullTarget, 1370 pub title: String, 1371 #[serde(default)] 1372 pub body: String, 1373 - pub patch: String, 1374 #[serde(rename = "createdAt")] 1375 pub created_at: String, 1376 } ··· 1380 pub author_did: String, 1381 pub rkey: String, 1382 pub pull: Pull, 1383 } 1384 1385 #[derive(Debug, Clone)]
··· 987 Ok(out) 988 } 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 + 1015 #[allow(clippy::too_many_arguments)] 1016 pub async fn create_pull( 1017 &self, ··· 1247 Some(pull.body.as_str()) 1248 }; 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 + 1255 let req = MergeReq { 1256 did: repo_did, 1257 name: repo_name, 1258 + patch: patch_str, 1259 branch: &pull.target.branch, 1260 commit_message: Some(&pull.title), 1261 commit_body, ··· 1395 } 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 + 1406 + #[derive(Debug, Clone, Serialize, Deserialize)] 1407 pub struct Pull { 1408 pub target: PullTarget, 1409 pub title: String, 1410 #[serde(default)] 1411 pub body: String, 1412 + #[serde(default)] 1413 + pub patch: Option<String>, 1414 + #[serde(default)] 1415 + pub source: Option<PullSource>, 1416 #[serde(rename = "createdAt")] 1417 pub created_at: String, 1418 } ··· 1422 pub author_did: String, 1423 pub rkey: String, 1424 pub pull: Pull, 1425 + } 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 1442 #[derive(Debug, Clone)]
+56 -18
crates/tangled-cli/src/commands/pr.rs
··· 21 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 22 .unwrap_or_else(|| "https://bsky.social".into()); 23 let client = tangled_api::TangledClient::new(&pds); 24 - let target_repo_at = if let Some(repo) = &args.repo { 25 let (owner, name) = parse_repo_ref(repo, &session.handle); 26 let info = client 27 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 28 .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)"); 42 } else { 43 - println!("RKEY\tTITLE\tTARGET"); 44 - for pr in pulls { 45 - println!("{}\t{}\t{}", pr.rkey, pr.pull.title, pr.pull.target.repo); 46 } 47 } 48 Ok(()) ··· 135 println!("BODY:\n{}", pr.body); 136 } 137 println!("TARGET: {} @ {}", pr.target.repo, pr.target.branch); 138 if args.diff { 139 - println!("PATCH:\n{}", pr.patch); 140 } 141 Ok(()) 142 }
··· 21 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 22 .unwrap_or_else(|| "https://bsky.social".into()); 23 let client = tangled_api::TangledClient::new(&pds); 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 { 27 let (owner, name) = parse_repo_ref(repo, &session.handle); 28 let info = client 29 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 30 .await?; 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 + } 54 } else { 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 + } 70 } 71 } 72 Ok(()) ··· 159 println!("BODY:\n{}", pr.body); 160 } 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 + 172 if args.diff { 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 + } 178 } 179 Ok(()) 180 }