Rust CLI for tangled
at feature/list-all-pulls 1540 lines 46 kB view raw
1use anyhow::{anyhow, Result}; 2use serde::{de::DeserializeOwned, Deserialize, Serialize}; 3use tangled_config::session::Session; 4 5#[derive(Clone, Debug)] 6pub struct TangledClient { 7 base_url: String, 8} 9 10const REPO_CREATE: &str = "sh.tangled.repo.create"; 11 12impl Default for TangledClient { 13 fn default() -> Self { 14 Self::new("https://tngl.sh") 15 } 16} 17 18impl TangledClient { 19 pub fn new(base_url: impl Into<String>) -> Self { 20 Self { 21 base_url: base_url.into(), 22 } 23 } 24 25 fn xrpc_url(&self, method: &str) -> String { 26 let base = self.base_url.trim_end_matches('/'); 27 // Add https:// if no protocol is present 28 let base_with_protocol = if base.starts_with("http://") || base.starts_with("https://") { 29 base.to_string() 30 } else { 31 format!("https://{}", base) 32 }; 33 format!("{}/xrpc/{}", base_with_protocol, method) 34 } 35 36 async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( 37 &self, 38 method: &str, 39 req: &TReq, 40 bearer: Option<&str>, 41 ) -> Result<TRes> { 42 let url = self.xrpc_url(method); 43 let client = reqwest::Client::new(); 44 let mut reqb = client 45 .post(url) 46 .header(reqwest::header::CONTENT_TYPE, "application/json"); 47 if let Some(token) = bearer { 48 reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 49 } 50 let res = reqb.json(req).send().await?; 51 let status = res.status(); 52 if !status.is_success() { 53 let body = res.text().await.unwrap_or_default(); 54 return Err(anyhow!("{}: {}", status, body)); 55 } 56 Ok(res.json::<TRes>().await?) 57 } 58 59 async fn post<TReq: Serialize>( 60 &self, 61 method: &str, 62 req: &TReq, 63 bearer: Option<&str>, 64 ) -> Result<()> { 65 let url = self.xrpc_url(method); 66 let client = reqwest::Client::new(); 67 let mut reqb = client 68 .post(url) 69 .header(reqwest::header::CONTENT_TYPE, "application/json"); 70 if let Some(token) = bearer { 71 reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 72 } 73 let res = reqb.json(req).send().await?; 74 let status = res.status(); 75 if !status.is_success() { 76 let body = res.text().await.unwrap_or_default(); 77 return Err(anyhow!("{}: {}", status, body)); 78 } 79 Ok(()) 80 } 81 82 pub async fn get_json<TRes: DeserializeOwned>( 83 &self, 84 method: &str, 85 params: &[(&str, String)], 86 bearer: Option<&str>, 87 ) -> Result<TRes> { 88 let url = self.xrpc_url(method); 89 let client = reqwest::Client::new(); 90 let mut reqb = client 91 .get(&url) 92 .query(&params) 93 .header(reqwest::header::ACCEPT, "application/json"); 94 if let Some(token) = bearer { 95 reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 96 } 97 let res = reqb.send().await?; 98 let status = res.status(); 99 let body = res.text().await.unwrap_or_default(); 100 if !status.is_success() { 101 return Err(anyhow!("GET {} -> {}: {}", url, status, body)); 102 } 103 serde_json::from_str::<TRes>(&body).map_err(|e| { 104 let snippet = body.chars().take(300).collect::<String>(); 105 anyhow!( 106 "error decoding response from {}: {}\nBody (first 300 chars): {}", 107 url, 108 e, 109 snippet 110 ) 111 }) 112 } 113 114 pub async fn login_with_password( 115 &self, 116 handle: &str, 117 password: &str, 118 _pds: &str, 119 ) -> Result<Session> { 120 #[derive(Serialize)] 121 struct Req<'a> { 122 #[serde(rename = "identifier")] 123 identifier: &'a str, 124 #[serde(rename = "password")] 125 password: &'a str, 126 } 127 #[derive(Deserialize)] 128 struct Res { 129 #[serde(rename = "accessJwt")] 130 access_jwt: String, 131 #[serde(rename = "refreshJwt")] 132 refresh_jwt: String, 133 did: String, 134 handle: String, 135 } 136 let body = Req { 137 identifier: handle, 138 password, 139 }; 140 let res: Res = self 141 .post_json("com.atproto.server.createSession", &body, None) 142 .await?; 143 Ok(Session { 144 access_jwt: res.access_jwt, 145 refresh_jwt: res.refresh_jwt, 146 did: res.did, 147 handle: res.handle, 148 ..Default::default() 149 }) 150 } 151 152 pub async fn refresh_session(&self, refresh_jwt: &str) -> Result<Session> { 153 #[derive(Deserialize)] 154 struct Res { 155 #[serde(rename = "accessJwt")] 156 access_jwt: String, 157 #[serde(rename = "refreshJwt")] 158 refresh_jwt: String, 159 did: String, 160 handle: String, 161 } 162 let url = self.xrpc_url("com.atproto.server.refreshSession"); 163 let client = reqwest::Client::new(); 164 let res = client 165 .post(url) 166 .header(reqwest::header::AUTHORIZATION, format!("Bearer {}", refresh_jwt)) 167 .send() 168 .await?; 169 let status = res.status(); 170 if !status.is_success() { 171 let body = res.text().await.unwrap_or_default(); 172 return Err(anyhow!("{}: {}", status, body)); 173 } 174 let res_data: Res = res.json().await?; 175 Ok(Session { 176 access_jwt: res_data.access_jwt, 177 refresh_jwt: res_data.refresh_jwt, 178 did: res_data.did, 179 handle: res_data.handle, 180 ..Default::default() 181 }) 182 } 183 184 pub async fn list_repos( 185 &self, 186 user: Option<&str>, 187 knot: Option<&str>, 188 starred: bool, 189 bearer: Option<&str>, 190 ) -> Result<Vec<Repository>> { 191 // NOTE: Repo listing is done via the user's PDS using com.atproto.repo.listRecords 192 // for the collection "sh.tangled.repo". This does not go through the Tangled API base. 193 // Here, `self.base_url` must be the PDS base (e.g., https://bsky.social). 194 // Resolve handle to DID if needed 195 let did = match user { 196 Some(u) if u.starts_with("did:") => u.to_string(), 197 Some(handle) => { 198 #[derive(Deserialize)] 199 struct Res { 200 did: String, 201 } 202 let params = [("handle", handle.to_string())]; 203 let res: Res = self 204 .get_json("com.atproto.identity.resolveHandle", &params, bearer) 205 .await?; 206 res.did 207 } 208 None => { 209 return Err(anyhow!( 210 "missing user for list_repos; provide handle or DID" 211 )); 212 } 213 }; 214 215 #[derive(Deserialize)] 216 struct RecordItem { 217 uri: String, 218 value: Repository, 219 } 220 #[derive(Deserialize)] 221 struct ListRes { 222 #[serde(default)] 223 records: Vec<RecordItem>, 224 } 225 226 let params = vec![ 227 ("repo", did), 228 ("collection", "sh.tangled.repo".to_string()), 229 ("limit", "100".to_string()), 230 ]; 231 232 let res: ListRes = self 233 .get_json("com.atproto.repo.listRecords", &params, bearer) 234 .await?; 235 let mut repos: Vec<Repository> = res 236 .records 237 .into_iter() 238 .map(|r| { 239 let mut val = r.value; 240 if val.rkey.is_none() { 241 if let Some(k) = Self::uri_rkey(&r.uri) { 242 val.rkey = Some(k); 243 } 244 } 245 if val.did.is_none() { 246 if let Some(d) = Self::uri_did(&r.uri) { 247 val.did = Some(d); 248 } 249 } 250 val 251 }) 252 .collect(); 253 // Apply optional filters client-side 254 if let Some(k) = knot { 255 repos.retain(|r| r.knot.as_deref().unwrap_or("") == k); 256 } 257 if starred { 258 // TODO: implement starred filtering when API is available. For now, no-op. 259 } 260 Ok(repos) 261 } 262 263 pub async fn create_repo(&self, opts: CreateRepoOptions<'_>) -> Result<()> { 264 // 1) Create the sh.tangled.repo record on the user's PDS 265 #[derive(Serialize)] 266 struct Record<'a> { 267 name: &'a str, 268 knot: &'a str, 269 #[serde(skip_serializing_if = "Option::is_none")] 270 description: Option<&'a str>, 271 #[serde(rename = "createdAt")] 272 created_at: String, 273 } 274 #[derive(Serialize)] 275 struct CreateRecordReq<'a> { 276 repo: &'a str, 277 collection: &'a str, 278 validate: bool, 279 record: Record<'a>, 280 } 281 #[derive(Deserialize)] 282 struct CreateRecordRes { 283 uri: String, 284 } 285 286 let now = chrono::Utc::now().to_rfc3339(); 287 let rec = Record { 288 name: opts.name, 289 knot: opts.knot, 290 description: opts.description, 291 created_at: now, 292 }; 293 let create_req = CreateRecordReq { 294 repo: opts.did, 295 collection: "sh.tangled.repo", 296 validate: true, 297 record: rec, 298 }; 299 300 let pds_client = TangledClient::new(opts.pds_base); 301 let created: CreateRecordRes = pds_client 302 .post_json( 303 "com.atproto.repo.createRecord", 304 &create_req, 305 Some(opts.access_jwt), 306 ) 307 .await?; 308 309 // Extract rkey from at-uri: at://did/collection/rkey 310 let rkey = created 311 .uri 312 .rsplit('/') 313 .next() 314 .ok_or_else(|| anyhow!("failed to parse rkey from uri"))?; 315 316 // 2) Obtain a service auth token for the Tangled server (aud = did:web:<host>) 317 let host = self 318 .base_url 319 .trim_end_matches('/') 320 .strip_prefix("https://") 321 .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 322 .ok_or_else(|| anyhow!("invalid base_url"))?; 323 let audience = format!("did:web:{}", host); 324 325 #[derive(Deserialize)] 326 struct GetSARes { 327 token: String, 328 } 329 // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 330 let params = [ 331 ("aud", audience), 332 ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 333 ]; 334 let sa: GetSARes = pds_client 335 .get_json( 336 "com.atproto.server.getServiceAuth", 337 &params, 338 Some(opts.access_jwt), 339 ) 340 .await?; 341 342 // 3) Call sh.tangled.repo.create with the rkey 343 #[derive(Serialize)] 344 struct CreateRepoReq<'a> { 345 rkey: &'a str, 346 #[serde(skip_serializing_if = "Option::is_none")] 347 #[serde(rename = "defaultBranch")] 348 default_branch: Option<&'a str>, 349 #[serde(skip_serializing_if = "Option::is_none")] 350 source: Option<&'a str>, 351 } 352 let req = CreateRepoReq { 353 rkey, 354 default_branch: opts.default_branch, 355 source: opts.source, 356 }; 357 // No output expected on success 358 let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?; 359 Ok(()) 360 } 361 362 pub async fn get_repo_info( 363 &self, 364 owner: &str, 365 name: &str, 366 bearer: Option<&str>, 367 ) -> Result<RepoRecord> { 368 let did = if owner.starts_with("did:") { 369 owner.to_string() 370 } else { 371 #[derive(Deserialize)] 372 struct Res { 373 did: String, 374 } 375 let params = [("handle", owner.to_string())]; 376 let res: Res = self 377 .get_json("com.atproto.identity.resolveHandle", &params, bearer) 378 .await?; 379 res.did 380 }; 381 382 #[derive(Deserialize)] 383 struct RecordItem { 384 uri: String, 385 value: Repository, 386 } 387 #[derive(Deserialize)] 388 struct ListRes { 389 #[serde(default)] 390 records: Vec<RecordItem>, 391 } 392 let params = vec![ 393 ("repo", did.clone()), 394 ("collection", "sh.tangled.repo".to_string()), 395 ("limit", "100".to_string()), 396 ]; 397 let res: ListRes = self 398 .get_json("com.atproto.repo.listRecords", &params, bearer) 399 .await?; 400 for item in res.records { 401 if item.value.name == name { 402 let rkey = 403 Self::uri_rkey(&item.uri).ok_or_else(|| anyhow!("missing rkey in uri"))?; 404 let knot = item.value.knot.unwrap_or_default(); 405 return Ok(RepoRecord { 406 did: did.clone(), 407 name: name.to_string(), 408 rkey, 409 knot, 410 description: item.value.description, 411 spindle: item.value.spindle, 412 }); 413 } 414 } 415 Err(anyhow!("repo not found for owner/name")) 416 } 417 418 pub async fn delete_repo( 419 &self, 420 did: &str, 421 name: &str, 422 pds_base: &str, 423 access_jwt: &str, 424 ) -> Result<()> { 425 let pds_client = TangledClient::new(pds_base); 426 let info = pds_client 427 .get_repo_info(did, name, Some(access_jwt)) 428 .await?; 429 430 #[derive(Serialize)] 431 struct DeleteRecordReq<'a> { 432 repo: &'a str, 433 collection: &'a str, 434 rkey: &'a str, 435 } 436 let del = DeleteRecordReq { 437 repo: did, 438 collection: "sh.tangled.repo", 439 rkey: &info.rkey, 440 }; 441 let _: serde_json::Value = pds_client 442 .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) 443 .await?; 444 445 let host = self 446 .base_url 447 .trim_end_matches('/') 448 .strip_prefix("https://") 449 .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 450 .ok_or_else(|| anyhow!("invalid base_url"))?; 451 let audience = format!("did:web:{}", host); 452 #[derive(Deserialize)] 453 struct GetSARes { 454 token: String, 455 } 456 // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 457 let params = [ 458 ("aud", audience), 459 ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 460 ]; 461 let sa: GetSARes = pds_client 462 .get_json( 463 "com.atproto.server.getServiceAuth", 464 &params, 465 Some(access_jwt), 466 ) 467 .await?; 468 469 #[derive(Serialize)] 470 struct DeleteReq<'a> { 471 did: &'a str, 472 name: &'a str, 473 rkey: &'a str, 474 } 475 let body = DeleteReq { 476 did, 477 name, 478 rkey: &info.rkey, 479 }; 480 let _: serde_json::Value = self 481 .post_json("sh.tangled.repo.delete", &body, Some(&sa.token)) 482 .await?; 483 Ok(()) 484 } 485 486 pub async fn update_repo_knot( 487 &self, 488 did: &str, 489 rkey: &str, 490 new_knot: &str, 491 pds_base: &str, 492 access_jwt: &str, 493 ) -> Result<()> { 494 let pds_client = TangledClient::new(pds_base); 495 #[derive(Deserialize, Serialize, Clone)] 496 struct Rec { 497 name: String, 498 knot: String, 499 #[serde(skip_serializing_if = "Option::is_none")] 500 description: Option<String>, 501 #[serde(rename = "createdAt")] 502 created_at: String, 503 } 504 #[derive(Deserialize)] 505 struct GetRes { 506 value: Rec, 507 } 508 let params = [ 509 ("repo", did.to_string()), 510 ("collection", "sh.tangled.repo".to_string()), 511 ("rkey", rkey.to_string()), 512 ]; 513 let got: GetRes = pds_client 514 .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 515 .await?; 516 let mut rec = got.value; 517 rec.knot = new_knot.to_string(); 518 #[derive(Serialize)] 519 struct PutReq<'a> { 520 repo: &'a str, 521 collection: &'a str, 522 rkey: &'a str, 523 validate: bool, 524 record: Rec, 525 } 526 let req = PutReq { 527 repo: did, 528 collection: "sh.tangled.repo", 529 rkey, 530 validate: true, 531 record: rec, 532 }; 533 let _: serde_json::Value = pds_client 534 .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 535 .await?; 536 Ok(()) 537 } 538 539 pub async fn get_default_branch( 540 &self, 541 knot_host: &str, 542 did: &str, 543 name: &str, 544 ) -> Result<DefaultBranch> { 545 #[derive(Deserialize)] 546 struct Res { 547 name: String, 548 hash: String, 549 #[serde(rename = "shortHash")] 550 short_hash: Option<String>, 551 when: String, 552 message: Option<String>, 553 } 554 let knot_client = TangledClient::new(knot_host); 555 let repo_param = format!("{}/{}", did, name); 556 let params = [("repo", repo_param)]; 557 let res: Res = knot_client 558 .get_json("sh.tangled.repo.getDefaultBranch", &params, None) 559 .await?; 560 Ok(DefaultBranch { 561 name: res.name, 562 hash: res.hash, 563 short_hash: res.short_hash, 564 when: res.when, 565 message: res.message, 566 }) 567 } 568 569 pub async fn get_languages(&self, knot_host: &str, did: &str, name: &str) -> Result<Languages> { 570 let knot_client = TangledClient::new(knot_host); 571 let repo_param = format!("{}/{}", did, name); 572 let params = [("repo", repo_param)]; 573 let res: serde_json::Value = knot_client 574 .get_json("sh.tangled.repo.languages", &params, None) 575 .await?; 576 let langs = res 577 .get("languages") 578 .cloned() 579 .unwrap_or(serde_json::json!([])); 580 let languages: Vec<Language> = serde_json::from_value(langs)?; 581 let total_size = res.get("totalSize").and_then(|v| v.as_u64()); 582 let total_files = res.get("totalFiles").and_then(|v| v.as_u64()); 583 Ok(Languages { 584 languages, 585 total_size, 586 total_files, 587 }) 588 } 589 590 pub async fn star_repo( 591 &self, 592 pds_base: &str, 593 access_jwt: &str, 594 subject_at_uri: &str, 595 user_did: &str, 596 ) -> Result<String> { 597 #[derive(Serialize)] 598 struct Rec<'a> { 599 subject: &'a str, 600 #[serde(rename = "createdAt")] 601 created_at: String, 602 } 603 #[derive(Serialize)] 604 struct Req<'a> { 605 repo: &'a str, 606 collection: &'a str, 607 validate: bool, 608 record: Rec<'a>, 609 } 610 #[derive(Deserialize)] 611 struct Res { 612 uri: String, 613 } 614 let now = chrono::Utc::now().to_rfc3339(); 615 let rec = Rec { 616 subject: subject_at_uri, 617 created_at: now, 618 }; 619 let req = Req { 620 repo: user_did, 621 collection: "sh.tangled.feed.star", 622 validate: true, 623 record: rec, 624 }; 625 let pds_client = TangledClient::new(pds_base); 626 let res: Res = pds_client 627 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 628 .await?; 629 let rkey = Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in star uri"))?; 630 Ok(rkey) 631 } 632 633 pub async fn unstar_repo( 634 &self, 635 pds_base: &str, 636 access_jwt: &str, 637 subject_at_uri: &str, 638 user_did: &str, 639 ) -> Result<()> { 640 #[derive(Deserialize)] 641 struct Item { 642 uri: String, 643 value: StarRecord, 644 } 645 #[derive(Deserialize)] 646 struct ListRes { 647 #[serde(default)] 648 records: Vec<Item>, 649 } 650 let pds_client = TangledClient::new(pds_base); 651 let params = vec![ 652 ("repo", user_did.to_string()), 653 ("collection", "sh.tangled.feed.star".to_string()), 654 ("limit", "100".to_string()), 655 ]; 656 let res: ListRes = pds_client 657 .get_json("com.atproto.repo.listRecords", &params, Some(access_jwt)) 658 .await?; 659 let mut rkey = None; 660 for item in res.records { 661 if item.value.subject == subject_at_uri { 662 rkey = Self::uri_rkey(&item.uri); 663 if rkey.is_some() { 664 break; 665 } 666 } 667 } 668 let rkey = rkey.ok_or_else(|| anyhow!("star record not found"))?; 669 #[derive(Serialize)] 670 struct Del<'a> { 671 repo: &'a str, 672 collection: &'a str, 673 rkey: &'a str, 674 } 675 let del = Del { 676 repo: user_did, 677 collection: "sh.tangled.feed.star", 678 rkey: &rkey, 679 }; 680 let _: serde_json::Value = pds_client 681 .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) 682 .await?; 683 Ok(()) 684 } 685 686 fn uri_rkey(uri: &str) -> Option<String> { 687 uri.rsplit('/').next().map(|s| s.to_string()) 688 } 689 fn uri_did(uri: &str) -> Option<String> { 690 let parts: Vec<&str> = uri.split('/').collect(); 691 if parts.len() >= 3 { 692 Some(parts[2].to_string()) 693 } else { 694 None 695 } 696 } 697 698 // ========== Issues ========== 699 pub async fn list_issues( 700 &self, 701 author_did: &str, 702 repo_at_uri: Option<&str>, 703 bearer: Option<&str>, 704 ) -> Result<Vec<IssueRecord>> { 705 #[derive(Deserialize)] 706 struct Item { 707 uri: String, 708 value: Issue, 709 } 710 #[derive(Deserialize)] 711 struct ListRes { 712 #[serde(default)] 713 records: Vec<Item>, 714 } 715 let params = vec![ 716 ("repo", author_did.to_string()), 717 ("collection", "sh.tangled.repo.issue".to_string()), 718 ("limit", "100".to_string()), 719 ]; 720 let res: ListRes = self 721 .get_json("com.atproto.repo.listRecords", &params, bearer) 722 .await?; 723 let mut out = vec![]; 724 for it in res.records { 725 if let Some(filter_repo) = repo_at_uri { 726 if it.value.repo.as_str() != filter_repo { 727 continue; 728 } 729 } 730 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 731 out.push(IssueRecord { 732 author_did: author_did.to_string(), 733 rkey, 734 issue: it.value, 735 }); 736 } 737 Ok(out) 738 } 739 740 #[allow(clippy::too_many_arguments)] 741 pub async fn create_issue( 742 &self, 743 author_did: &str, 744 repo_did: &str, 745 repo_rkey: &str, 746 title: &str, 747 body: Option<&str>, 748 pds_base: &str, 749 access_jwt: &str, 750 ) -> Result<String> { 751 #[derive(Serialize)] 752 struct Rec<'a> { 753 repo: &'a str, 754 title: &'a str, 755 #[serde(skip_serializing_if = "Option::is_none")] 756 body: Option<&'a str>, 757 #[serde(rename = "createdAt")] 758 created_at: String, 759 } 760 #[derive(Serialize)] 761 struct Req<'a> { 762 repo: &'a str, 763 collection: &'a str, 764 validate: bool, 765 record: Rec<'a>, 766 } 767 #[derive(Deserialize)] 768 struct Res { 769 uri: String, 770 } 771 let issue_repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); 772 let now = chrono::Utc::now().to_rfc3339(); 773 let rec = Rec { 774 repo: &issue_repo_at, 775 title, 776 body, 777 created_at: now, 778 }; 779 let req = Req { 780 repo: author_did, 781 collection: "sh.tangled.repo.issue", 782 validate: true, 783 record: rec, 784 }; 785 let pds_client = TangledClient::new(pds_base); 786 let res: Res = pds_client 787 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 788 .await?; 789 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue uri")) 790 } 791 792 pub async fn comment_issue( 793 &self, 794 author_did: &str, 795 issue_at: &str, 796 body: &str, 797 pds_base: &str, 798 access_jwt: &str, 799 ) -> Result<String> { 800 #[derive(Serialize)] 801 struct Rec<'a> { 802 issue: &'a str, 803 body: &'a str, 804 #[serde(rename = "createdAt")] 805 created_at: String, 806 } 807 #[derive(Serialize)] 808 struct Req<'a> { 809 repo: &'a str, 810 collection: &'a str, 811 validate: bool, 812 record: Rec<'a>, 813 } 814 #[derive(Deserialize)] 815 struct Res { 816 uri: String, 817 } 818 let now = chrono::Utc::now().to_rfc3339(); 819 let rec = Rec { 820 issue: issue_at, 821 body, 822 created_at: now, 823 }; 824 let req = Req { 825 repo: author_did, 826 collection: "sh.tangled.repo.issue.comment", 827 validate: true, 828 record: rec, 829 }; 830 let pds_client = TangledClient::new(pds_base); 831 let res: Res = pds_client 832 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 833 .await?; 834 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue comment uri")) 835 } 836 837 pub async fn get_issue_record( 838 &self, 839 author_did: &str, 840 rkey: &str, 841 bearer: Option<&str>, 842 ) -> Result<Issue> { 843 #[derive(Deserialize)] 844 struct GetRes { 845 value: Issue, 846 } 847 let params = [ 848 ("repo", author_did.to_string()), 849 ("collection", "sh.tangled.repo.issue".to_string()), 850 ("rkey", rkey.to_string()), 851 ]; 852 let res: GetRes = self 853 .get_json("com.atproto.repo.getRecord", &params, bearer) 854 .await?; 855 Ok(res.value) 856 } 857 858 pub async fn put_issue_record( 859 &self, 860 author_did: &str, 861 rkey: &str, 862 record: &Issue, 863 bearer: Option<&str>, 864 ) -> Result<()> { 865 #[derive(Serialize)] 866 struct PutReq<'a> { 867 repo: &'a str, 868 collection: &'a str, 869 rkey: &'a str, 870 validate: bool, 871 record: &'a Issue, 872 } 873 let req = PutReq { 874 repo: author_did, 875 collection: "sh.tangled.repo.issue", 876 rkey, 877 validate: true, 878 record, 879 }; 880 let _: serde_json::Value = self 881 .post_json("com.atproto.repo.putRecord", &req, bearer) 882 .await?; 883 Ok(()) 884 } 885 886 pub async fn set_issue_state( 887 &self, 888 author_did: &str, 889 issue_at: &str, 890 state_nsid: &str, 891 pds_base: &str, 892 access_jwt: &str, 893 ) -> Result<String> { 894 #[derive(Serialize)] 895 struct Rec<'a> { 896 issue: &'a str, 897 state: &'a str, 898 } 899 #[derive(Serialize)] 900 struct Req<'a> { 901 repo: &'a str, 902 collection: &'a str, 903 validate: bool, 904 record: Rec<'a>, 905 } 906 #[derive(Deserialize)] 907 struct Res { 908 uri: String, 909 } 910 let rec = Rec { 911 issue: issue_at, 912 state: state_nsid, 913 }; 914 let req = Req { 915 repo: author_did, 916 collection: "sh.tangled.repo.issue.state", 917 validate: true, 918 record: rec, 919 }; 920 let pds_client = TangledClient::new(pds_base); 921 let res: Res = pds_client 922 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 923 .await?; 924 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri")) 925 } 926 927 pub async fn get_pull_record( 928 &self, 929 author_did: &str, 930 rkey: &str, 931 bearer: Option<&str>, 932 ) -> Result<Pull> { 933 #[derive(Deserialize)] 934 struct GetRes { 935 value: Pull, 936 } 937 let params = [ 938 ("repo", author_did.to_string()), 939 ("collection", "sh.tangled.repo.pull".to_string()), 940 ("rkey", rkey.to_string()), 941 ]; 942 let res: GetRes = self 943 .get_json("com.atproto.repo.getRecord", &params, bearer) 944 .await?; 945 Ok(res.value) 946 } 947 948 // ========== Pull Requests ========== 949 pub async fn list_pulls( 950 &self, 951 author_did: &str, 952 target_repo_at_uri: Option<&str>, 953 bearer: Option<&str>, 954 ) -> Result<Vec<PullRecord>> { 955 #[derive(Deserialize)] 956 struct Item { 957 uri: String, 958 value: Pull, 959 } 960 #[derive(Deserialize)] 961 struct ListRes { 962 #[serde(default)] 963 records: Vec<Item>, 964 } 965 let params = vec![ 966 ("repo", author_did.to_string()), 967 ("collection", "sh.tangled.repo.pull".to_string()), 968 ("limit", "100".to_string()), 969 ]; 970 let res: ListRes = self 971 .get_json("com.atproto.repo.listRecords", &params, bearer) 972 .await?; 973 let mut out = vec![]; 974 for it in res.records { 975 if let Some(target) = target_repo_at_uri { 976 if it.value.target.repo.as_str() != target { 977 continue; 978 } 979 } 980 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 981 out.push(PullRecord { 982 author_did: author_did.to_string(), 983 rkey, 984 pull: it.value, 985 }); 986 } 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, 1018 author_did: &str, 1019 repo_did: &str, 1020 repo_rkey: &str, 1021 target_branch: &str, 1022 patch: &str, 1023 title: &str, 1024 body: Option<&str>, 1025 pds_base: &str, 1026 access_jwt: &str, 1027 ) -> Result<String> { 1028 #[derive(Serialize)] 1029 struct Target<'a> { 1030 repo: &'a str, 1031 branch: &'a str, 1032 } 1033 #[derive(Serialize)] 1034 struct Rec<'a> { 1035 target: Target<'a>, 1036 title: &'a str, 1037 #[serde(skip_serializing_if = "Option::is_none")] 1038 body: Option<&'a str>, 1039 patch: &'a str, 1040 #[serde(rename = "createdAt")] 1041 created_at: String, 1042 } 1043 #[derive(Serialize)] 1044 struct Req<'a> { 1045 repo: &'a str, 1046 collection: &'a str, 1047 validate: bool, 1048 record: Rec<'a>, 1049 } 1050 #[derive(Deserialize)] 1051 struct Res { 1052 uri: String, 1053 } 1054 let repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); 1055 let now = chrono::Utc::now().to_rfc3339(); 1056 let rec = Rec { 1057 target: Target { 1058 repo: &repo_at, 1059 branch: target_branch, 1060 }, 1061 title, 1062 body, 1063 patch, 1064 created_at: now, 1065 }; 1066 let req = Req { 1067 repo: author_did, 1068 collection: "sh.tangled.repo.pull", 1069 validate: true, 1070 record: rec, 1071 }; 1072 let pds_client = TangledClient::new(pds_base); 1073 let res: Res = pds_client 1074 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1075 .await?; 1076 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull uri")) 1077 } 1078 1079 // ========== Spindle: Secrets Management ========== 1080 pub async fn list_repo_secrets( 1081 &self, 1082 pds_base: &str, 1083 access_jwt: &str, 1084 repo_at: &str, 1085 ) -> Result<Vec<Secret>> { 1086 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1087 #[derive(Deserialize)] 1088 struct Res { 1089 secrets: Vec<Secret>, 1090 } 1091 let params = [("repo", repo_at.to_string())]; 1092 let res: Res = self 1093 .get_json("sh.tangled.repo.listSecrets", &params, Some(&sa)) 1094 .await?; 1095 Ok(res.secrets) 1096 } 1097 1098 pub async fn add_repo_secret( 1099 &self, 1100 pds_base: &str, 1101 access_jwt: &str, 1102 repo_at: &str, 1103 key: &str, 1104 value: &str, 1105 ) -> Result<()> { 1106 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1107 #[derive(Serialize)] 1108 struct Req<'a> { 1109 repo: &'a str, 1110 key: &'a str, 1111 value: &'a str, 1112 } 1113 let body = Req { 1114 repo: repo_at, 1115 key, 1116 value, 1117 }; 1118 self.post("sh.tangled.repo.addSecret", &body, Some(&sa)) 1119 .await 1120 } 1121 1122 pub async fn remove_repo_secret( 1123 &self, 1124 pds_base: &str, 1125 access_jwt: &str, 1126 repo_at: &str, 1127 key: &str, 1128 ) -> Result<()> { 1129 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1130 #[derive(Serialize)] 1131 struct Req<'a> { 1132 repo: &'a str, 1133 key: &'a str, 1134 } 1135 let body = Req { repo: repo_at, key }; 1136 self.post("sh.tangled.repo.removeSecret", &body, Some(&sa)) 1137 .await 1138 } 1139 1140 async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> { 1141 let base_trimmed = self.base_url.trim_end_matches('/'); 1142 let host = base_trimmed 1143 .strip_prefix("https://") 1144 .or_else(|| base_trimmed.strip_prefix("http://")) 1145 .unwrap_or(base_trimmed); // If no protocol, use the URL as-is 1146 let audience = format!("did:web:{}", host); 1147 #[derive(Deserialize)] 1148 struct GetSARes { 1149 token: String, 1150 } 1151 let pds = TangledClient::new(pds_base); 1152 // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 1153 let params = [ 1154 ("aud", audience), 1155 ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 1156 ]; 1157 let sa: GetSARes = pds 1158 .get_json( 1159 "com.atproto.server.getServiceAuth", 1160 &params, 1161 Some(access_jwt), 1162 ) 1163 .await?; 1164 Ok(sa.token) 1165 } 1166 1167 pub async fn comment_pull( 1168 &self, 1169 author_did: &str, 1170 pull_at: &str, 1171 body: &str, 1172 pds_base: &str, 1173 access_jwt: &str, 1174 ) -> Result<String> { 1175 #[derive(Serialize)] 1176 struct Rec<'a> { 1177 pull: &'a str, 1178 body: &'a str, 1179 #[serde(rename = "createdAt")] 1180 created_at: String, 1181 } 1182 #[derive(Serialize)] 1183 struct Req<'a> { 1184 repo: &'a str, 1185 collection: &'a str, 1186 validate: bool, 1187 record: Rec<'a>, 1188 } 1189 #[derive(Deserialize)] 1190 struct Res { 1191 uri: String, 1192 } 1193 let now = chrono::Utc::now().to_rfc3339(); 1194 let rec = Rec { 1195 pull: pull_at, 1196 body, 1197 created_at: now, 1198 }; 1199 let req = Req { 1200 repo: author_did, 1201 collection: "sh.tangled.repo.pull.comment", 1202 validate: true, 1203 record: rec, 1204 }; 1205 let pds_client = TangledClient::new(pds_base); 1206 let res: Res = pds_client 1207 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1208 .await?; 1209 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri")) 1210 } 1211 1212 pub async fn merge_pull( 1213 &self, 1214 pull_did: &str, 1215 pull_rkey: &str, 1216 repo_did: &str, 1217 repo_name: &str, 1218 pds_base: &str, 1219 access_jwt: &str, 1220 ) -> Result<()> { 1221 // Fetch the pull request to get patch and target branch 1222 let pds_client = TangledClient::new(pds_base); 1223 let pull = pds_client 1224 .get_pull_record(pull_did, pull_rkey, Some(access_jwt)) 1225 .await?; 1226 1227 // Get service auth token for the knot 1228 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1229 1230 #[derive(Serialize)] 1231 struct MergeReq<'a> { 1232 did: &'a str, 1233 name: &'a str, 1234 patch: &'a str, 1235 branch: &'a str, 1236 #[serde(skip_serializing_if = "Option::is_none")] 1237 #[serde(rename = "commitMessage")] 1238 commit_message: Option<&'a str>, 1239 #[serde(skip_serializing_if = "Option::is_none")] 1240 #[serde(rename = "commitBody")] 1241 commit_body: Option<&'a str>, 1242 } 1243 1244 let commit_body = if pull.body.is_empty() { 1245 None 1246 } else { 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, 1262 }; 1263 1264 let _: serde_json::Value = self 1265 .post_json("sh.tangled.repo.merge", &req, Some(&sa)) 1266 .await?; 1267 Ok(()) 1268 } 1269 1270 pub async fn update_repo_spindle( 1271 &self, 1272 did: &str, 1273 rkey: &str, 1274 new_spindle: Option<&str>, 1275 pds_base: &str, 1276 access_jwt: &str, 1277 ) -> Result<()> { 1278 let pds_client = TangledClient::new(pds_base); 1279 #[derive(Deserialize, Serialize, Clone)] 1280 struct Rec { 1281 name: String, 1282 knot: String, 1283 #[serde(skip_serializing_if = "Option::is_none")] 1284 description: Option<String>, 1285 #[serde(skip_serializing_if = "Option::is_none")] 1286 spindle: Option<String>, 1287 #[serde(rename = "createdAt")] 1288 created_at: String, 1289 } 1290 #[derive(Deserialize)] 1291 struct GetRes { 1292 value: Rec, 1293 } 1294 let params = [ 1295 ("repo", did.to_string()), 1296 ("collection", "sh.tangled.repo".to_string()), 1297 ("rkey", rkey.to_string()), 1298 ]; 1299 let got: GetRes = pds_client 1300 .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 1301 .await?; 1302 let mut rec = got.value; 1303 rec.spindle = new_spindle.map(|s| s.to_string()); 1304 #[derive(Serialize)] 1305 struct PutReq<'a> { 1306 repo: &'a str, 1307 collection: &'a str, 1308 rkey: &'a str, 1309 validate: bool, 1310 record: Rec, 1311 } 1312 let req = PutReq { 1313 repo: did, 1314 collection: "sh.tangled.repo", 1315 rkey, 1316 validate: true, 1317 record: rec, 1318 }; 1319 let _: serde_json::Value = pds_client 1320 .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 1321 .await?; 1322 Ok(()) 1323 } 1324 1325 pub async fn list_pipelines( 1326 &self, 1327 repo_did: &str, 1328 bearer: Option<&str>, 1329 ) -> Result<Vec<PipelineRecord>> { 1330 #[derive(Deserialize)] 1331 struct Item { 1332 uri: String, 1333 value: Pipeline, 1334 } 1335 #[derive(Deserialize)] 1336 struct ListRes { 1337 #[serde(default)] 1338 records: Vec<Item>, 1339 } 1340 let params = vec![ 1341 ("repo", repo_did.to_string()), 1342 ("collection", "sh.tangled.pipeline".to_string()), 1343 ("limit", "100".to_string()), 1344 ]; 1345 let res: ListRes = self 1346 .get_json("com.atproto.repo.listRecords", &params, bearer) 1347 .await?; 1348 let mut out = vec![]; 1349 for it in res.records { 1350 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 1351 out.push(PipelineRecord { 1352 rkey, 1353 pipeline: it.value, 1354 }); 1355 } 1356 Ok(out) 1357 } 1358} 1359 1360#[derive(Debug, Clone, Serialize, Deserialize, Default)] 1361pub struct Repository { 1362 pub did: Option<String>, 1363 pub rkey: Option<String>, 1364 pub name: String, 1365 pub knot: Option<String>, 1366 pub description: Option<String>, 1367 pub spindle: Option<String>, 1368 #[serde(default)] 1369 pub private: bool, 1370} 1371 1372// Issue record value 1373#[derive(Debug, Clone, Serialize, Deserialize)] 1374pub struct Issue { 1375 pub repo: String, 1376 pub title: String, 1377 #[serde(default)] 1378 pub body: String, 1379 #[serde(rename = "createdAt")] 1380 pub created_at: String, 1381} 1382 1383#[derive(Debug, Clone)] 1384pub struct IssueRecord { 1385 pub author_did: String, 1386 pub rkey: String, 1387 pub issue: Issue, 1388} 1389 1390// Pull record value (subset) 1391#[derive(Debug, Clone, Serialize, Deserialize)] 1392pub struct PullTarget { 1393 pub repo: String, 1394 pub branch: String, 1395} 1396 1397#[derive(Debug, Clone, Serialize, Deserialize)] 1398pub 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)] 1407pub 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} 1419 1420#[derive(Debug, Clone)] 1421pub struct PullRecord { 1422 pub author_did: String, 1423 pub rkey: String, 1424 pub pull: Pull, 1425} 1426 1427#[derive(Debug, Clone, Deserialize)] 1428pub 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)] 1443pub struct RepoRecord { 1444 pub did: String, 1445 pub name: String, 1446 pub rkey: String, 1447 pub knot: String, 1448 pub description: Option<String>, 1449 pub spindle: Option<String>, 1450} 1451 1452#[derive(Debug, Clone, Serialize, Deserialize)] 1453pub struct DefaultBranch { 1454 pub name: String, 1455 pub hash: String, 1456 #[serde(skip_serializing_if = "Option::is_none")] 1457 pub short_hash: Option<String>, 1458 pub when: String, 1459 #[serde(skip_serializing_if = "Option::is_none")] 1460 pub message: Option<String>, 1461} 1462 1463#[derive(Debug, Clone, Serialize, Deserialize)] 1464pub struct Language { 1465 pub name: String, 1466 pub size: u64, 1467 pub percentage: u64, 1468} 1469 1470#[derive(Debug, Clone, Serialize, Deserialize)] 1471pub struct Languages { 1472 pub languages: Vec<Language>, 1473 #[serde(skip_serializing_if = "Option::is_none")] 1474 pub total_size: Option<u64>, 1475 #[serde(skip_serializing_if = "Option::is_none")] 1476 pub total_files: Option<u64>, 1477} 1478 1479#[derive(Debug, Clone, Serialize, Deserialize)] 1480pub struct StarRecord { 1481 pub subject: String, 1482 #[serde(rename = "createdAt")] 1483 pub created_at: String, 1484} 1485 1486#[derive(Debug, Clone, Serialize, Deserialize)] 1487pub struct Secret { 1488 pub repo: String, 1489 pub key: String, 1490 #[serde(rename = "createdAt")] 1491 pub created_at: String, 1492 #[serde(rename = "createdBy")] 1493 pub created_by: String, 1494} 1495 1496#[derive(Debug, Clone)] 1497pub struct CreateRepoOptions<'a> { 1498 pub did: &'a str, 1499 pub name: &'a str, 1500 pub knot: &'a str, 1501 pub description: Option<&'a str>, 1502 pub default_branch: Option<&'a str>, 1503 pub source: Option<&'a str>, 1504 pub pds_base: &'a str, 1505 pub access_jwt: &'a str, 1506} 1507 1508#[derive(Debug, Clone, Serialize, Deserialize)] 1509pub struct TriggerMetadata { 1510 pub kind: String, 1511 pub repo: TriggerRepo, 1512} 1513 1514#[derive(Debug, Clone, Serialize, Deserialize)] 1515pub struct TriggerRepo { 1516 pub knot: String, 1517 pub did: String, 1518 pub repo: String, 1519 #[serde(rename = "defaultBranch")] 1520 pub default_branch: String, 1521} 1522 1523#[derive(Debug, Clone, Serialize, Deserialize)] 1524pub struct Workflow { 1525 pub name: String, 1526 pub engine: String, 1527} 1528 1529#[derive(Debug, Clone, Serialize, Deserialize)] 1530pub struct Pipeline { 1531 #[serde(rename = "triggerMetadata")] 1532 pub trigger_metadata: TriggerMetadata, 1533 pub workflows: Vec<Workflow>, 1534} 1535 1536#[derive(Debug, Clone)] 1537pub struct PipelineRecord { 1538 pub rkey: String, 1539 pub pipeline: Pipeline, 1540}