at main 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: false, 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 get_repo_by_rkey( 419 &self, 420 did: &str, 421 rkey: &str, 422 bearer: Option<&str>, 423 ) -> Result<Repository> { 424 #[derive(Deserialize)] 425 struct GetRes { 426 value: Repository, 427 } 428 let params = [ 429 ("repo", did.to_string()), 430 ("collection", "sh.tangled.repo".to_string()), 431 ("rkey", rkey.to_string()), 432 ]; 433 let res: GetRes = self 434 .get_json("com.atproto.repo.getRecord", &params, bearer) 435 .await?; 436 Ok(res.value) 437 } 438 439 pub async fn resolve_did_to_handle( 440 &self, 441 did: &str, 442 bearer: Option<&str>, 443 ) -> Result<String> { 444 #[derive(Deserialize)] 445 struct Res { 446 handle: String, 447 } 448 let params = [("repo", did.to_string())]; 449 let res: Res = self 450 .get_json("com.atproto.repo.describeRepo", &params, bearer) 451 .await?; 452 Ok(res.handle) 453 } 454 455 pub async fn delete_repo( 456 &self, 457 did: &str, 458 name: &str, 459 pds_base: &str, 460 access_jwt: &str, 461 ) -> Result<()> { 462 let pds_client = TangledClient::new(pds_base); 463 let info = pds_client 464 .get_repo_info(did, name, Some(access_jwt)) 465 .await?; 466 467 #[derive(Serialize)] 468 struct DeleteRecordReq<'a> { 469 repo: &'a str, 470 collection: &'a str, 471 rkey: &'a str, 472 } 473 let del = DeleteRecordReq { 474 repo: did, 475 collection: "sh.tangled.repo", 476 rkey: &info.rkey, 477 }; 478 let _: serde_json::Value = pds_client 479 .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) 480 .await?; 481 482 let host = self 483 .base_url 484 .trim_end_matches('/') 485 .strip_prefix("https://") 486 .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 487 .ok_or_else(|| anyhow!("invalid base_url"))?; 488 let audience = format!("did:web:{}", host); 489 #[derive(Deserialize)] 490 struct GetSARes { 491 token: String, 492 } 493 // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 494 let params = [ 495 ("aud", audience), 496 ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 497 ]; 498 let sa: GetSARes = pds_client 499 .get_json( 500 "com.atproto.server.getServiceAuth", 501 &params, 502 Some(access_jwt), 503 ) 504 .await?; 505 506 #[derive(Serialize)] 507 struct DeleteReq<'a> { 508 did: &'a str, 509 name: &'a str, 510 rkey: &'a str, 511 } 512 let body = DeleteReq { 513 did, 514 name, 515 rkey: &info.rkey, 516 }; 517 let _: serde_json::Value = self 518 .post_json("sh.tangled.repo.delete", &body, Some(&sa.token)) 519 .await?; 520 Ok(()) 521 } 522 523 pub async fn update_repo_knot( 524 &self, 525 did: &str, 526 rkey: &str, 527 new_knot: &str, 528 pds_base: &str, 529 access_jwt: &str, 530 ) -> Result<()> { 531 let pds_client = TangledClient::new(pds_base); 532 #[derive(Deserialize, Serialize, Clone)] 533 struct Rec { 534 name: String, 535 knot: String, 536 #[serde(skip_serializing_if = "Option::is_none")] 537 description: Option<String>, 538 #[serde(rename = "createdAt")] 539 created_at: String, 540 } 541 #[derive(Deserialize)] 542 struct GetRes { 543 value: Rec, 544 } 545 let params = [ 546 ("repo", did.to_string()), 547 ("collection", "sh.tangled.repo".to_string()), 548 ("rkey", rkey.to_string()), 549 ]; 550 let got: GetRes = pds_client 551 .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 552 .await?; 553 let mut rec = got.value; 554 rec.knot = new_knot.to_string(); 555 #[derive(Serialize)] 556 struct PutReq<'a> { 557 repo: &'a str, 558 collection: &'a str, 559 rkey: &'a str, 560 validate: bool, 561 record: Rec, 562 } 563 let req = PutReq { 564 repo: did, 565 collection: "sh.tangled.repo", 566 rkey, 567 validate: false, 568 record: rec, 569 }; 570 let _: serde_json::Value = pds_client 571 .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 572 .await?; 573 Ok(()) 574 } 575 576 pub async fn get_default_branch( 577 &self, 578 knot_host: &str, 579 did: &str, 580 name: &str, 581 ) -> Result<DefaultBranch> { 582 #[derive(Deserialize)] 583 struct Res { 584 name: String, 585 hash: String, 586 #[serde(rename = "shortHash")] 587 short_hash: Option<String>, 588 when: String, 589 message: Option<String>, 590 } 591 let knot_client = TangledClient::new(knot_host); 592 let repo_param = format!("{}/{}", did, name); 593 let params = [("repo", repo_param)]; 594 let res: Res = knot_client 595 .get_json("sh.tangled.repo.getDefaultBranch", &params, None) 596 .await?; 597 Ok(DefaultBranch { 598 name: res.name, 599 hash: res.hash, 600 short_hash: res.short_hash, 601 when: res.when, 602 message: res.message, 603 }) 604 } 605 606 pub async fn get_languages(&self, knot_host: &str, did: &str, name: &str) -> Result<Languages> { 607 let knot_client = TangledClient::new(knot_host); 608 let repo_param = format!("{}/{}", did, name); 609 let params = [("repo", repo_param)]; 610 let res: serde_json::Value = knot_client 611 .get_json("sh.tangled.repo.languages", &params, None) 612 .await?; 613 let langs = res 614 .get("languages") 615 .cloned() 616 .unwrap_or(serde_json::json!([])); 617 let languages: Vec<Language> = serde_json::from_value(langs)?; 618 let total_size = res.get("totalSize").and_then(|v| v.as_u64()); 619 let total_files = res.get("totalFiles").and_then(|v| v.as_u64()); 620 Ok(Languages { 621 languages, 622 total_size, 623 total_files, 624 }) 625 } 626 627 pub async fn star_repo( 628 &self, 629 pds_base: &str, 630 access_jwt: &str, 631 subject_at_uri: &str, 632 user_did: &str, 633 ) -> Result<String> { 634 #[derive(Serialize)] 635 struct Rec<'a> { 636 subject: &'a str, 637 #[serde(rename = "createdAt")] 638 created_at: String, 639 } 640 #[derive(Serialize)] 641 struct Req<'a> { 642 repo: &'a str, 643 collection: &'a str, 644 validate: bool, 645 record: Rec<'a>, 646 } 647 #[derive(Deserialize)] 648 struct Res { 649 uri: String, 650 } 651 let now = chrono::Utc::now().to_rfc3339(); 652 let rec = Rec { 653 subject: subject_at_uri, 654 created_at: now, 655 }; 656 let req = Req { 657 repo: user_did, 658 collection: "sh.tangled.feed.star", 659 validate: false, 660 record: rec, 661 }; 662 let pds_client = TangledClient::new(pds_base); 663 let res: Res = pds_client 664 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 665 .await?; 666 let rkey = Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in star uri"))?; 667 Ok(rkey) 668 } 669 670 pub async fn unstar_repo( 671 &self, 672 pds_base: &str, 673 access_jwt: &str, 674 subject_at_uri: &str, 675 user_did: &str, 676 ) -> Result<()> { 677 #[derive(Deserialize)] 678 struct Item { 679 uri: String, 680 value: StarRecord, 681 } 682 #[derive(Deserialize)] 683 struct ListRes { 684 #[serde(default)] 685 records: Vec<Item>, 686 } 687 let pds_client = TangledClient::new(pds_base); 688 let params = vec![ 689 ("repo", user_did.to_string()), 690 ("collection", "sh.tangled.feed.star".to_string()), 691 ("limit", "100".to_string()), 692 ]; 693 let res: ListRes = pds_client 694 .get_json("com.atproto.repo.listRecords", &params, Some(access_jwt)) 695 .await?; 696 let mut rkey = None; 697 for item in res.records { 698 if item.value.subject == subject_at_uri { 699 rkey = Self::uri_rkey(&item.uri); 700 if rkey.is_some() { 701 break; 702 } 703 } 704 } 705 let rkey = rkey.ok_or_else(|| anyhow!("star record not found"))?; 706 #[derive(Serialize)] 707 struct Del<'a> { 708 repo: &'a str, 709 collection: &'a str, 710 rkey: &'a str, 711 } 712 let del = Del { 713 repo: user_did, 714 collection: "sh.tangled.feed.star", 715 rkey: &rkey, 716 }; 717 let _: serde_json::Value = pds_client 718 .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) 719 .await?; 720 Ok(()) 721 } 722 723 fn uri_rkey(uri: &str) -> Option<String> { 724 uri.rsplit('/').next().map(|s| s.to_string()) 725 } 726 fn uri_did(uri: &str) -> Option<String> { 727 let parts: Vec<&str> = uri.split('/').collect(); 728 if parts.len() >= 3 { 729 Some(parts[2].to_string()) 730 } else { 731 None 732 } 733 } 734 735 // ========== Issues ========== 736 pub async fn list_issues( 737 &self, 738 author_did: &str, 739 repo_at_uri: Option<&str>, 740 bearer: Option<&str>, 741 ) -> Result<Vec<IssueRecord>> { 742 #[derive(Deserialize)] 743 struct Item { 744 uri: String, 745 #[allow(dead_code)] 746 cid: Option<String>, 747 value: Issue, 748 } 749 #[derive(Deserialize)] 750 struct ListRes { 751 #[serde(default)] 752 records: Vec<Item>, 753 } 754 let params = vec![ 755 ("repo", author_did.to_string()), 756 ("collection", "sh.tangled.repo.issue".to_string()), 757 ("limit", "100".to_string()), 758 ]; 759 let res: ListRes = self 760 .get_json("com.atproto.repo.listRecords", &params, bearer) 761 .await?; 762 let mut out = vec![]; 763 for it in res.records { 764 if let Some(filter_repo) = repo_at_uri { 765 if it.value.repo.as_str() != filter_repo { 766 continue; 767 } 768 } 769 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 770 out.push(IssueRecord { 771 author_did: author_did.to_string(), 772 rkey, 773 issue: it.value, 774 }); 775 } 776 Ok(out) 777 } 778 779 #[allow(clippy::too_many_arguments)] 780 pub async fn create_issue( 781 &self, 782 author_did: &str, 783 repo_did: &str, 784 repo_rkey: &str, 785 title: &str, 786 body: Option<&str>, 787 pds_base: &str, 788 access_jwt: &str, 789 ) -> Result<String> { 790 #[derive(Serialize)] 791 struct Rec<'a> { 792 repo: &'a str, 793 title: &'a str, 794 #[serde(skip_serializing_if = "Option::is_none")] 795 body: Option<&'a str>, 796 #[serde(rename = "createdAt")] 797 created_at: String, 798 } 799 #[derive(Serialize)] 800 struct Req<'a> { 801 repo: &'a str, 802 collection: &'a str, 803 validate: bool, 804 record: Rec<'a>, 805 } 806 #[derive(Deserialize)] 807 struct Res { 808 uri: String, 809 } 810 let issue_repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); 811 let now = chrono::Utc::now().to_rfc3339(); 812 let rec = Rec { 813 repo: &issue_repo_at, 814 title, 815 body, 816 created_at: now, 817 }; 818 let req = Req { 819 repo: author_did, 820 collection: "sh.tangled.repo.issue", 821 validate: false, 822 record: rec, 823 }; 824 let pds_client = TangledClient::new(pds_base); 825 let res: Res = pds_client 826 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 827 .await?; 828 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue uri")) 829 } 830 831 pub async fn comment_issue( 832 &self, 833 author_did: &str, 834 issue_at: &str, 835 body: &str, 836 pds_base: &str, 837 access_jwt: &str, 838 ) -> Result<String> { 839 #[derive(Serialize)] 840 struct Rec<'a> { 841 issue: &'a str, 842 body: &'a str, 843 #[serde(rename = "createdAt")] 844 created_at: String, 845 } 846 #[derive(Serialize)] 847 struct Req<'a> { 848 repo: &'a str, 849 collection: &'a str, 850 validate: bool, 851 record: Rec<'a>, 852 } 853 #[derive(Deserialize)] 854 struct Res { 855 uri: String, 856 } 857 let now = chrono::Utc::now().to_rfc3339(); 858 let rec = Rec { 859 issue: issue_at, 860 body, 861 created_at: now, 862 }; 863 let req = Req { 864 repo: author_did, 865 collection: "sh.tangled.repo.issue.comment", 866 validate: false, 867 record: rec, 868 }; 869 let pds_client = TangledClient::new(pds_base); 870 let res: Res = pds_client 871 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 872 .await?; 873 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue comment uri")) 874 } 875 876 pub async fn get_issue_record( 877 &self, 878 author_did: &str, 879 rkey: &str, 880 bearer: Option<&str>, 881 ) -> Result<Issue> { 882 #[derive(Deserialize)] 883 struct GetRes { 884 value: Issue, 885 } 886 let params = [ 887 ("repo", author_did.to_string()), 888 ("collection", "sh.tangled.repo.issue".to_string()), 889 ("rkey", rkey.to_string()), 890 ]; 891 let res: GetRes = self 892 .get_json("com.atproto.repo.getRecord", &params, bearer) 893 .await?; 894 Ok(res.value) 895 } 896 897 pub async fn put_issue_record( 898 &self, 899 author_did: &str, 900 rkey: &str, 901 record: &Issue, 902 bearer: Option<&str>, 903 ) -> Result<()> { 904 #[derive(Serialize)] 905 struct PutReq<'a> { 906 repo: &'a str, 907 collection: &'a str, 908 rkey: &'a str, 909 validate: bool, 910 record: &'a Issue, 911 } 912 let req = PutReq { 913 repo: author_did, 914 collection: "sh.tangled.repo.issue", 915 rkey, 916 validate: false, 917 record, 918 }; 919 let _: serde_json::Value = self 920 .post_json("com.atproto.repo.putRecord", &req, bearer) 921 .await?; 922 Ok(()) 923 } 924 925 pub async fn set_issue_state( 926 &self, 927 author_did: &str, 928 issue_at: &str, 929 state_nsid: &str, 930 pds_base: &str, 931 access_jwt: &str, 932 ) -> Result<String> { 933 #[derive(Serialize)] 934 struct Rec<'a> { 935 issue: &'a str, 936 state: &'a str, 937 } 938 #[derive(Serialize)] 939 struct Req<'a> { 940 repo: &'a str, 941 collection: &'a str, 942 validate: bool, 943 record: Rec<'a>, 944 } 945 #[derive(Deserialize)] 946 struct Res { 947 uri: String, 948 } 949 let rec = Rec { 950 issue: issue_at, 951 state: state_nsid, 952 }; 953 let req = Req { 954 repo: author_did, 955 collection: "sh.tangled.repo.issue.state", 956 validate: false, 957 record: rec, 958 }; 959 let pds_client = TangledClient::new(pds_base); 960 let res: Res = pds_client 961 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 962 .await?; 963 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri")) 964 } 965 966 pub async fn list_issue_states( 967 &self, 968 author_did: &str, 969 bearer: Option<&str>, 970 ) -> Result<Vec<IssueState>> { 971 #[derive(Deserialize)] 972 struct Item { 973 #[allow(dead_code)] 974 uri: String, 975 #[allow(dead_code)] 976 cid: Option<String>, 977 value: IssueState, 978 } 979 #[derive(Deserialize)] 980 struct ListRes { 981 #[serde(default)] 982 records: Vec<Item>, 983 } 984 let params = vec![ 985 ("repo", author_did.to_string()), 986 ("collection", "sh.tangled.repo.issue.state".to_string()), 987 ("limit", "100".to_string()), 988 ]; 989 let res: ListRes = self 990 .get_json("com.atproto.repo.listRecords", &params, bearer) 991 .await?; 992 Ok(res.records.into_iter().map(|it| it.value).collect()) 993 } 994 995 pub async fn get_pull_record( 996 &self, 997 author_did: &str, 998 rkey: &str, 999 bearer: Option<&str>, 1000 ) -> Result<Pull> { 1001 #[derive(Deserialize)] 1002 struct GetRes { 1003 value: Pull, 1004 } 1005 let params = [ 1006 ("repo", author_did.to_string()), 1007 ("collection", "sh.tangled.repo.pull".to_string()), 1008 ("rkey", rkey.to_string()), 1009 ]; 1010 let res: GetRes = self 1011 .get_json("com.atproto.repo.getRecord", &params, bearer) 1012 .await?; 1013 Ok(res.value) 1014 } 1015 1016 // ========== Pull Requests ========== 1017 pub async fn list_pulls( 1018 &self, 1019 author_did: &str, 1020 target_repo_at_uri: Option<&str>, 1021 bearer: Option<&str>, 1022 ) -> Result<Vec<PullRecord>> { 1023 #[derive(Deserialize)] 1024 struct Item { 1025 uri: String, 1026 value: Pull, 1027 } 1028 #[derive(Deserialize)] 1029 struct ListRes { 1030 #[serde(default)] 1031 records: Vec<Item>, 1032 } 1033 let params = vec![ 1034 ("repo", author_did.to_string()), 1035 ("collection", "sh.tangled.repo.pull".to_string()), 1036 ("limit", "100".to_string()), 1037 ]; 1038 let res: ListRes = self 1039 .get_json("com.atproto.repo.listRecords", &params, bearer) 1040 .await?; 1041 let mut out = vec![]; 1042 for it in res.records { 1043 if let Some(target) = target_repo_at_uri { 1044 if it.value.target.repo.as_str() != target { 1045 continue; 1046 } 1047 } 1048 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 1049 out.push(PullRecord { 1050 author_did: author_did.to_string(), 1051 rkey, 1052 pull: it.value, 1053 }); 1054 } 1055 Ok(out) 1056 } 1057 1058 #[allow(clippy::too_many_arguments)] 1059 pub async fn create_pull( 1060 &self, 1061 author_did: &str, 1062 repo_did: &str, 1063 repo_rkey: &str, 1064 target_branch: &str, 1065 patch: &str, 1066 title: &str, 1067 body: Option<&str>, 1068 pds_base: &str, 1069 access_jwt: &str, 1070 ) -> Result<String> { 1071 #[derive(Serialize)] 1072 struct Target<'a> { 1073 repo: &'a str, 1074 branch: &'a str, 1075 } 1076 #[derive(Serialize)] 1077 struct Rec<'a> { 1078 target: Target<'a>, 1079 title: &'a str, 1080 #[serde(skip_serializing_if = "Option::is_none")] 1081 body: Option<&'a str>, 1082 patch: &'a str, 1083 #[serde(rename = "createdAt")] 1084 created_at: String, 1085 } 1086 #[derive(Serialize)] 1087 struct Req<'a> { 1088 repo: &'a str, 1089 collection: &'a str, 1090 validate: bool, 1091 record: Rec<'a>, 1092 } 1093 #[derive(Deserialize)] 1094 struct Res { 1095 uri: String, 1096 } 1097 let repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); 1098 let now = chrono::Utc::now().to_rfc3339(); 1099 let rec = Rec { 1100 target: Target { 1101 repo: &repo_at, 1102 branch: target_branch, 1103 }, 1104 title, 1105 body, 1106 patch, 1107 created_at: now, 1108 }; 1109 let req = Req { 1110 repo: author_did, 1111 collection: "sh.tangled.repo.pull", 1112 validate: false, 1113 record: rec, 1114 }; 1115 let pds_client = TangledClient::new(pds_base); 1116 let res: Res = pds_client 1117 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1118 .await?; 1119 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull uri")) 1120 } 1121 1122 // ========== Spindle: Secrets Management ========== 1123 pub async fn list_repo_secrets( 1124 &self, 1125 pds_base: &str, 1126 access_jwt: &str, 1127 repo_at: &str, 1128 ) -> Result<Vec<Secret>> { 1129 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1130 #[derive(Deserialize)] 1131 struct Res { 1132 secrets: Vec<Secret>, 1133 } 1134 let params = [("repo", repo_at.to_string())]; 1135 let res: Res = self 1136 .get_json("sh.tangled.repo.listSecrets", &params, Some(&sa)) 1137 .await?; 1138 Ok(res.secrets) 1139 } 1140 1141 pub async fn add_repo_secret( 1142 &self, 1143 pds_base: &str, 1144 access_jwt: &str, 1145 repo_at: &str, 1146 key: &str, 1147 value: &str, 1148 ) -> Result<()> { 1149 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1150 #[derive(Serialize)] 1151 struct Req<'a> { 1152 repo: &'a str, 1153 key: &'a str, 1154 value: &'a str, 1155 } 1156 let body = Req { 1157 repo: repo_at, 1158 key, 1159 value, 1160 }; 1161 self.post("sh.tangled.repo.addSecret", &body, Some(&sa)) 1162 .await 1163 } 1164 1165 pub async fn remove_repo_secret( 1166 &self, 1167 pds_base: &str, 1168 access_jwt: &str, 1169 repo_at: &str, 1170 key: &str, 1171 ) -> Result<()> { 1172 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1173 #[derive(Serialize)] 1174 struct Req<'a> { 1175 repo: &'a str, 1176 key: &'a str, 1177 } 1178 let body = Req { repo: repo_at, key }; 1179 self.post("sh.tangled.repo.removeSecret", &body, Some(&sa)) 1180 .await 1181 } 1182 1183 async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> { 1184 let base_trimmed = self.base_url.trim_end_matches('/'); 1185 let host = base_trimmed 1186 .strip_prefix("https://") 1187 .or_else(|| base_trimmed.strip_prefix("http://")) 1188 .unwrap_or(base_trimmed); // If no protocol, use the URL as-is 1189 let audience = format!("did:web:{}", host); 1190 #[derive(Deserialize)] 1191 struct GetSARes { 1192 token: String, 1193 } 1194 let pds = TangledClient::new(pds_base); 1195 // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 1196 let params = [ 1197 ("aud", audience), 1198 ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 1199 ]; 1200 let sa: GetSARes = pds 1201 .get_json( 1202 "com.atproto.server.getServiceAuth", 1203 &params, 1204 Some(access_jwt), 1205 ) 1206 .await?; 1207 Ok(sa.token) 1208 } 1209 1210 pub async fn comment_pull( 1211 &self, 1212 author_did: &str, 1213 pull_at: &str, 1214 body: &str, 1215 pds_base: &str, 1216 access_jwt: &str, 1217 ) -> Result<String> { 1218 #[derive(Serialize)] 1219 struct Rec<'a> { 1220 pull: &'a str, 1221 body: &'a str, 1222 #[serde(rename = "createdAt")] 1223 created_at: String, 1224 } 1225 #[derive(Serialize)] 1226 struct Req<'a> { 1227 repo: &'a str, 1228 collection: &'a str, 1229 validate: bool, 1230 record: Rec<'a>, 1231 } 1232 #[derive(Deserialize)] 1233 struct Res { 1234 uri: String, 1235 } 1236 let now = chrono::Utc::now().to_rfc3339(); 1237 let rec = Rec { 1238 pull: pull_at, 1239 body, 1240 created_at: now, 1241 }; 1242 let req = Req { 1243 repo: author_did, 1244 collection: "sh.tangled.repo.pull.comment", 1245 validate: false, 1246 record: rec, 1247 }; 1248 let pds_client = TangledClient::new(pds_base); 1249 let res: Res = pds_client 1250 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1251 .await?; 1252 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri")) 1253 } 1254 1255 pub async fn merge_pull( 1256 &self, 1257 pull_did: &str, 1258 pull_rkey: &str, 1259 repo_did: &str, 1260 repo_name: &str, 1261 pds_base: &str, 1262 access_jwt: &str, 1263 ) -> Result<()> { 1264 // Fetch the pull request to get patch and target branch 1265 let pds_client = TangledClient::new(pds_base); 1266 let pull = pds_client 1267 .get_pull_record(pull_did, pull_rkey, Some(access_jwt)) 1268 .await?; 1269 1270 // Get service auth token for the knot 1271 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1272 1273 #[derive(Serialize)] 1274 struct MergeReq<'a> { 1275 did: &'a str, 1276 name: &'a str, 1277 patch: &'a str, 1278 branch: &'a str, 1279 #[serde(skip_serializing_if = "Option::is_none")] 1280 #[serde(rename = "commitMessage")] 1281 commit_message: Option<&'a str>, 1282 #[serde(skip_serializing_if = "Option::is_none")] 1283 #[serde(rename = "commitBody")] 1284 commit_body: Option<&'a str>, 1285 } 1286 1287 let commit_body = if pull.body.is_empty() { 1288 None 1289 } else { 1290 Some(pull.body.as_str()) 1291 }; 1292 1293 let req = MergeReq { 1294 did: repo_did, 1295 name: repo_name, 1296 patch: &pull.patch, 1297 branch: &pull.target.branch, 1298 commit_message: Some(&pull.title), 1299 commit_body, 1300 }; 1301 1302 let _: serde_json::Value = self 1303 .post_json("sh.tangled.repo.merge", &req, Some(&sa)) 1304 .await?; 1305 Ok(()) 1306 } 1307 1308 pub async fn update_repo_spindle( 1309 &self, 1310 did: &str, 1311 rkey: &str, 1312 new_spindle: Option<&str>, 1313 pds_base: &str, 1314 access_jwt: &str, 1315 ) -> Result<()> { 1316 let pds_client = TangledClient::new(pds_base); 1317 #[derive(Deserialize, Serialize, Clone)] 1318 struct Rec { 1319 name: String, 1320 knot: String, 1321 #[serde(skip_serializing_if = "Option::is_none")] 1322 description: Option<String>, 1323 #[serde(skip_serializing_if = "Option::is_none")] 1324 spindle: Option<String>, 1325 #[serde(rename = "createdAt")] 1326 created_at: String, 1327 } 1328 #[derive(Deserialize)] 1329 struct GetRes { 1330 value: Rec, 1331 } 1332 let params = [ 1333 ("repo", did.to_string()), 1334 ("collection", "sh.tangled.repo".to_string()), 1335 ("rkey", rkey.to_string()), 1336 ]; 1337 let got: GetRes = pds_client 1338 .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 1339 .await?; 1340 let mut rec = got.value; 1341 rec.spindle = new_spindle.map(|s| s.to_string()); 1342 #[derive(Serialize)] 1343 struct PutReq<'a> { 1344 repo: &'a str, 1345 collection: &'a str, 1346 rkey: &'a str, 1347 validate: bool, 1348 record: Rec, 1349 } 1350 let req = PutReq { 1351 repo: did, 1352 collection: "sh.tangled.repo", 1353 rkey, 1354 validate: false, 1355 record: rec, 1356 }; 1357 let _: serde_json::Value = pds_client 1358 .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 1359 .await?; 1360 Ok(()) 1361 } 1362 1363 pub async fn list_pipelines( 1364 &self, 1365 repo_did: &str, 1366 bearer: Option<&str>, 1367 ) -> Result<Vec<PipelineRecord>> { 1368 #[derive(Deserialize)] 1369 struct Item { 1370 uri: String, 1371 value: Pipeline, 1372 } 1373 #[derive(Deserialize)] 1374 struct ListRes { 1375 #[serde(default)] 1376 records: Vec<Item>, 1377 } 1378 let params = vec![ 1379 ("repo", repo_did.to_string()), 1380 ("collection", "sh.tangled.pipeline".to_string()), 1381 ("limit", "100".to_string()), 1382 ]; 1383 let res: ListRes = self 1384 .get_json("com.atproto.repo.listRecords", &params, bearer) 1385 .await?; 1386 let mut out = vec![]; 1387 for it in res.records { 1388 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 1389 out.push(PipelineRecord { 1390 rkey, 1391 pipeline: it.value, 1392 }); 1393 } 1394 Ok(out) 1395 } 1396} 1397 1398#[derive(Debug, Clone, Serialize, Deserialize, Default)] 1399pub struct Repository { 1400 pub did: Option<String>, 1401 pub rkey: Option<String>, 1402 pub name: String, 1403 pub knot: Option<String>, 1404 pub description: Option<String>, 1405 pub spindle: Option<String>, 1406 #[serde(default)] 1407 pub private: bool, 1408} 1409 1410// Issue record value 1411#[derive(Debug, Clone, Serialize, Deserialize)] 1412pub struct Issue { 1413 pub repo: String, 1414 pub title: String, 1415 #[serde(default)] 1416 pub body: String, 1417 #[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")] 1418 pub created_at: Option<String>, 1419 #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 1420 pub record_type: Option<String>, 1421 #[serde(skip_serializing_if = "Option::is_none")] 1422 pub owner: Option<String>, 1423 #[serde(rename = "issueId", skip_serializing_if = "Option::is_none")] 1424 pub issue_id: Option<i64>, 1425} 1426 1427#[derive(Debug, Clone)] 1428pub struct IssueRecord { 1429 pub author_did: String, 1430 pub rkey: String, 1431 pub issue: Issue, 1432} 1433 1434#[derive(Debug, Clone, Serialize, Deserialize)] 1435pub struct IssueState { 1436 pub issue: String, 1437 pub state: String, 1438} 1439 1440// Pull record value (subset) 1441#[derive(Debug, Clone, Serialize, Deserialize)] 1442pub struct PullTarget { 1443 pub repo: String, 1444 pub branch: String, 1445} 1446 1447#[derive(Debug, Clone, Serialize, Deserialize)] 1448pub struct Pull { 1449 pub target: PullTarget, 1450 pub title: String, 1451 #[serde(default)] 1452 pub body: String, 1453 pub patch: String, 1454 #[serde(rename = "createdAt")] 1455 pub created_at: String, 1456} 1457 1458#[derive(Debug, Clone)] 1459pub struct PullRecord { 1460 pub author_did: String, 1461 pub rkey: String, 1462 pub pull: Pull, 1463} 1464 1465#[derive(Debug, Clone)] 1466pub struct RepoRecord { 1467 pub did: String, 1468 pub name: String, 1469 pub rkey: String, 1470 pub knot: String, 1471 pub description: Option<String>, 1472 pub spindle: Option<String>, 1473} 1474 1475#[derive(Debug, Clone, Serialize, Deserialize)] 1476pub struct DefaultBranch { 1477 pub name: String, 1478 pub hash: String, 1479 #[serde(skip_serializing_if = "Option::is_none")] 1480 pub short_hash: Option<String>, 1481 pub when: String, 1482 #[serde(skip_serializing_if = "Option::is_none")] 1483 pub message: Option<String>, 1484} 1485 1486#[derive(Debug, Clone, Serialize, Deserialize)] 1487pub struct Language { 1488 pub name: String, 1489 pub size: u64, 1490 pub percentage: u64, 1491} 1492 1493#[derive(Debug, Clone, Serialize, Deserialize)] 1494pub struct Languages { 1495 pub languages: Vec<Language>, 1496 #[serde(skip_serializing_if = "Option::is_none")] 1497 pub total_size: Option<u64>, 1498 #[serde(skip_serializing_if = "Option::is_none")] 1499 pub total_files: Option<u64>, 1500} 1501 1502#[derive(Debug, Clone, Serialize, Deserialize)] 1503pub struct StarRecord { 1504 pub subject: String, 1505 #[serde(rename = "createdAt")] 1506 pub created_at: String, 1507} 1508 1509#[derive(Debug, Clone, Serialize, Deserialize)] 1510pub struct Secret { 1511 pub repo: String, 1512 pub key: String, 1513 #[serde(rename = "createdAt")] 1514 pub created_at: String, 1515 #[serde(rename = "createdBy")] 1516 pub created_by: String, 1517} 1518 1519#[derive(Debug, Clone)] 1520pub struct CreateRepoOptions<'a> { 1521 pub did: &'a str, 1522 pub name: &'a str, 1523 pub knot: &'a str, 1524 pub description: Option<&'a str>, 1525 pub default_branch: Option<&'a str>, 1526 pub source: Option<&'a str>, 1527 pub pds_base: &'a str, 1528 pub access_jwt: &'a str, 1529} 1530 1531#[derive(Debug, Clone, Serialize, Deserialize)] 1532pub struct TriggerMetadata { 1533 pub kind: String, 1534 pub repo: TriggerRepo, 1535} 1536 1537#[derive(Debug, Clone, Serialize, Deserialize)] 1538pub struct TriggerRepo { 1539 pub knot: String, 1540 pub did: String, 1541 pub repo: String, 1542 #[serde(rename = "defaultBranch")] 1543 pub default_branch: String, 1544} 1545 1546#[derive(Debug, Clone, Serialize, Deserialize)] 1547pub struct Workflow { 1548 pub name: String, 1549 pub engine: String, 1550} 1551 1552#[derive(Debug, Clone, Serialize, Deserialize)] 1553pub struct Pipeline { 1554 #[serde(rename = "triggerMetadata")] 1555 pub trigger_metadata: TriggerMetadata, 1556 pub workflows: Vec<Workflow>, 1557} 1558 1559#[derive(Debug, Clone)] 1560pub struct PipelineRecord { 1561 pub rkey: String, 1562 pub pipeline: Pipeline, 1563}