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 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: false, 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: false, 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: false, 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: false, 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: false, 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: false, 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 #[allow(clippy::too_many_arguments)] 991 pub async fn create_pull( 992 &self, 993 author_did: &str, 994 repo_did: &str, 995 repo_rkey: &str, 996 target_branch: &str, 997 patch: &str, 998 title: &str, 999 body: Option<&str>, 1000 pds_base: &str, 1001 access_jwt: &str, 1002 ) -> Result<String> { 1003 #[derive(Serialize)] 1004 struct Target<'a> { 1005 repo: &'a str, 1006 branch: &'a str, 1007 } 1008 #[derive(Serialize)] 1009 struct Rec<'a> { 1010 target: Target<'a>, 1011 title: &'a str, 1012 #[serde(skip_serializing_if = "Option::is_none")] 1013 body: Option<&'a str>, 1014 patch: &'a str, 1015 #[serde(rename = "createdAt")] 1016 created_at: String, 1017 } 1018 #[derive(Serialize)] 1019 struct Req<'a> { 1020 repo: &'a str, 1021 collection: &'a str, 1022 validate: bool, 1023 record: Rec<'a>, 1024 } 1025 #[derive(Deserialize)] 1026 struct Res { 1027 uri: String, 1028 } 1029 let repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); 1030 let now = chrono::Utc::now().to_rfc3339(); 1031 let rec = Rec { 1032 target: Target { 1033 repo: &repo_at, 1034 branch: target_branch, 1035 }, 1036 title, 1037 body, 1038 patch, 1039 created_at: now, 1040 }; 1041 let req = Req { 1042 repo: author_did, 1043 collection: "sh.tangled.repo.pull", 1044 validate: false, 1045 record: rec, 1046 }; 1047 let pds_client = TangledClient::new(pds_base); 1048 let res: Res = pds_client 1049 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1050 .await?; 1051 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull uri")) 1052 } 1053 1054 // ========== Spindle: Secrets Management ========== 1055 pub async fn list_repo_secrets( 1056 &self, 1057 pds_base: &str, 1058 access_jwt: &str, 1059 repo_at: &str, 1060 ) -> Result<Vec<Secret>> { 1061 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1062 #[derive(Deserialize)] 1063 struct Res { 1064 secrets: Vec<Secret>, 1065 } 1066 let params = [("repo", repo_at.to_string())]; 1067 let res: Res = self 1068 .get_json("sh.tangled.repo.listSecrets", &params, Some(&sa)) 1069 .await?; 1070 Ok(res.secrets) 1071 } 1072 1073 pub async fn add_repo_secret( 1074 &self, 1075 pds_base: &str, 1076 access_jwt: &str, 1077 repo_at: &str, 1078 key: &str, 1079 value: &str, 1080 ) -> Result<()> { 1081 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1082 #[derive(Serialize)] 1083 struct Req<'a> { 1084 repo: &'a str, 1085 key: &'a str, 1086 value: &'a str, 1087 } 1088 let body = Req { 1089 repo: repo_at, 1090 key, 1091 value, 1092 }; 1093 self.post("sh.tangled.repo.addSecret", &body, Some(&sa)) 1094 .await 1095 } 1096 1097 pub async fn remove_repo_secret( 1098 &self, 1099 pds_base: &str, 1100 access_jwt: &str, 1101 repo_at: &str, 1102 key: &str, 1103 ) -> Result<()> { 1104 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1105 #[derive(Serialize)] 1106 struct Req<'a> { 1107 repo: &'a str, 1108 key: &'a str, 1109 } 1110 let body = Req { repo: repo_at, key }; 1111 self.post("sh.tangled.repo.removeSecret", &body, Some(&sa)) 1112 .await 1113 } 1114 1115 async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> { 1116 let base_trimmed = self.base_url.trim_end_matches('/'); 1117 let host = base_trimmed 1118 .strip_prefix("https://") 1119 .or_else(|| base_trimmed.strip_prefix("http://")) 1120 .unwrap_or(base_trimmed); // If no protocol, use the URL as-is 1121 let audience = format!("did:web:{}", host); 1122 #[derive(Deserialize)] 1123 struct GetSARes { 1124 token: String, 1125 } 1126 let pds = TangledClient::new(pds_base); 1127 // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 1128 let params = [ 1129 ("aud", audience), 1130 ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 1131 ]; 1132 let sa: GetSARes = pds 1133 .get_json( 1134 "com.atproto.server.getServiceAuth", 1135 &params, 1136 Some(access_jwt), 1137 ) 1138 .await?; 1139 Ok(sa.token) 1140 } 1141 1142 pub async fn comment_pull( 1143 &self, 1144 author_did: &str, 1145 pull_at: &str, 1146 body: &str, 1147 pds_base: &str, 1148 access_jwt: &str, 1149 ) -> Result<String> { 1150 #[derive(Serialize)] 1151 struct Rec<'a> { 1152 pull: &'a str, 1153 body: &'a str, 1154 #[serde(rename = "createdAt")] 1155 created_at: String, 1156 } 1157 #[derive(Serialize)] 1158 struct Req<'a> { 1159 repo: &'a str, 1160 collection: &'a str, 1161 validate: bool, 1162 record: Rec<'a>, 1163 } 1164 #[derive(Deserialize)] 1165 struct Res { 1166 uri: String, 1167 } 1168 let now = chrono::Utc::now().to_rfc3339(); 1169 let rec = Rec { 1170 pull: pull_at, 1171 body, 1172 created_at: now, 1173 }; 1174 let req = Req { 1175 repo: author_did, 1176 collection: "sh.tangled.repo.pull.comment", 1177 validate: false, 1178 record: rec, 1179 }; 1180 let pds_client = TangledClient::new(pds_base); 1181 let res: Res = pds_client 1182 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1183 .await?; 1184 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri")) 1185 } 1186 1187 pub async fn merge_pull( 1188 &self, 1189 pull_did: &str, 1190 pull_rkey: &str, 1191 repo_did: &str, 1192 repo_name: &str, 1193 pds_base: &str, 1194 access_jwt: &str, 1195 ) -> Result<()> { 1196 // Fetch the pull request to get patch and target branch 1197 let pds_client = TangledClient::new(pds_base); 1198 let pull = pds_client 1199 .get_pull_record(pull_did, pull_rkey, Some(access_jwt)) 1200 .await?; 1201 1202 // Get service auth token for the knot 1203 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1204 1205 #[derive(Serialize)] 1206 struct MergeReq<'a> { 1207 did: &'a str, 1208 name: &'a str, 1209 patch: &'a str, 1210 branch: &'a str, 1211 #[serde(skip_serializing_if = "Option::is_none")] 1212 #[serde(rename = "commitMessage")] 1213 commit_message: Option<&'a str>, 1214 #[serde(skip_serializing_if = "Option::is_none")] 1215 #[serde(rename = "commitBody")] 1216 commit_body: Option<&'a str>, 1217 } 1218 1219 let commit_body = if pull.body.is_empty() { 1220 None 1221 } else { 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, 1232 }; 1233 1234 let _: serde_json::Value = self 1235 .post_json("sh.tangled.repo.merge", &req, Some(&sa)) 1236 .await?; 1237 Ok(()) 1238 } 1239 1240 pub async fn update_repo_spindle( 1241 &self, 1242 did: &str, 1243 rkey: &str, 1244 new_spindle: Option<&str>, 1245 pds_base: &str, 1246 access_jwt: &str, 1247 ) -> Result<()> { 1248 let pds_client = TangledClient::new(pds_base); 1249 #[derive(Deserialize, Serialize, Clone)] 1250 struct Rec { 1251 name: String, 1252 knot: String, 1253 #[serde(skip_serializing_if = "Option::is_none")] 1254 description: Option<String>, 1255 #[serde(skip_serializing_if = "Option::is_none")] 1256 spindle: Option<String>, 1257 #[serde(rename = "createdAt")] 1258 created_at: String, 1259 } 1260 #[derive(Deserialize)] 1261 struct GetRes { 1262 value: Rec, 1263 } 1264 let params = [ 1265 ("repo", did.to_string()), 1266 ("collection", "sh.tangled.repo".to_string()), 1267 ("rkey", rkey.to_string()), 1268 ]; 1269 let got: GetRes = pds_client 1270 .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 1271 .await?; 1272 let mut rec = got.value; 1273 rec.spindle = new_spindle.map(|s| s.to_string()); 1274 #[derive(Serialize)] 1275 struct PutReq<'a> { 1276 repo: &'a str, 1277 collection: &'a str, 1278 rkey: &'a str, 1279 validate: bool, 1280 record: Rec, 1281 } 1282 let req = PutReq { 1283 repo: did, 1284 collection: "sh.tangled.repo", 1285 rkey, 1286 validate: false, 1287 record: rec, 1288 }; 1289 let _: serde_json::Value = pds_client 1290 .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 1291 .await?; 1292 Ok(()) 1293 } 1294 1295 pub async fn list_pipelines( 1296 &self, 1297 repo_did: &str, 1298 bearer: Option<&str>, 1299 ) -> Result<Vec<PipelineRecord>> { 1300 #[derive(Deserialize)] 1301 struct Item { 1302 uri: String, 1303 value: Pipeline, 1304 } 1305 #[derive(Deserialize)] 1306 struct ListRes { 1307 #[serde(default)] 1308 records: Vec<Item>, 1309 } 1310 let params = vec![ 1311 ("repo", repo_did.to_string()), 1312 ("collection", "sh.tangled.pipeline".to_string()), 1313 ("limit", "100".to_string()), 1314 ]; 1315 let res: ListRes = self 1316 .get_json("com.atproto.repo.listRecords", &params, bearer) 1317 .await?; 1318 let mut out = vec![]; 1319 for it in res.records { 1320 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 1321 out.push(PipelineRecord { 1322 rkey, 1323 pipeline: it.value, 1324 }); 1325 } 1326 Ok(out) 1327 } 1328} 1329 1330#[derive(Debug, Clone, Serialize, Deserialize, Default)] 1331pub struct Repository { 1332 pub did: Option<String>, 1333 pub rkey: Option<String>, 1334 pub name: String, 1335 pub knot: Option<String>, 1336 pub description: Option<String>, 1337 pub spindle: Option<String>, 1338 #[serde(default)] 1339 pub private: bool, 1340} 1341 1342// Issue record value 1343#[derive(Debug, Clone, Serialize, Deserialize)] 1344pub struct Issue { 1345 pub repo: String, 1346 pub title: String, 1347 #[serde(default)] 1348 pub body: String, 1349 #[serde(rename = "createdAt")] 1350 pub created_at: String, 1351} 1352 1353#[derive(Debug, Clone)] 1354pub struct IssueRecord { 1355 pub author_did: String, 1356 pub rkey: String, 1357 pub issue: Issue, 1358} 1359 1360// Pull record value (subset) 1361#[derive(Debug, Clone, Serialize, Deserialize)] 1362pub struct PullTarget { 1363 pub repo: String, 1364 pub branch: String, 1365} 1366 1367#[derive(Debug, Clone, Serialize, Deserialize)] 1368pub 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} 1377 1378#[derive(Debug, Clone)] 1379pub struct PullRecord { 1380 pub author_did: String, 1381 pub rkey: String, 1382 pub pull: Pull, 1383} 1384 1385#[derive(Debug, Clone)] 1386pub struct RepoRecord { 1387 pub did: String, 1388 pub name: String, 1389 pub rkey: String, 1390 pub knot: String, 1391 pub description: Option<String>, 1392 pub spindle: Option<String>, 1393} 1394 1395#[derive(Debug, Clone, Serialize, Deserialize)] 1396pub struct DefaultBranch { 1397 pub name: String, 1398 pub hash: String, 1399 #[serde(skip_serializing_if = "Option::is_none")] 1400 pub short_hash: Option<String>, 1401 pub when: String, 1402 #[serde(skip_serializing_if = "Option::is_none")] 1403 pub message: Option<String>, 1404} 1405 1406#[derive(Debug, Clone, Serialize, Deserialize)] 1407pub struct Language { 1408 pub name: String, 1409 pub size: u64, 1410 pub percentage: u64, 1411} 1412 1413#[derive(Debug, Clone, Serialize, Deserialize)] 1414pub struct Languages { 1415 pub languages: Vec<Language>, 1416 #[serde(skip_serializing_if = "Option::is_none")] 1417 pub total_size: Option<u64>, 1418 #[serde(skip_serializing_if = "Option::is_none")] 1419 pub total_files: Option<u64>, 1420} 1421 1422#[derive(Debug, Clone, Serialize, Deserialize)] 1423pub struct StarRecord { 1424 pub subject: String, 1425 #[serde(rename = "createdAt")] 1426 pub created_at: String, 1427} 1428 1429#[derive(Debug, Clone, Serialize, Deserialize)] 1430pub struct Secret { 1431 pub repo: String, 1432 pub key: String, 1433 #[serde(rename = "createdAt")] 1434 pub created_at: String, 1435 #[serde(rename = "createdBy")] 1436 pub created_by: String, 1437} 1438 1439#[derive(Debug, Clone)] 1440pub struct CreateRepoOptions<'a> { 1441 pub did: &'a str, 1442 pub name: &'a str, 1443 pub knot: &'a str, 1444 pub description: Option<&'a str>, 1445 pub default_branch: Option<&'a str>, 1446 pub source: Option<&'a str>, 1447 pub pds_base: &'a str, 1448 pub access_jwt: &'a str, 1449} 1450 1451#[derive(Debug, Clone, Serialize, Deserialize)] 1452pub struct TriggerMetadata { 1453 pub kind: String, 1454 pub repo: TriggerRepo, 1455} 1456 1457#[derive(Debug, Clone, Serialize, Deserialize)] 1458pub struct TriggerRepo { 1459 pub knot: String, 1460 pub did: String, 1461 pub repo: String, 1462 #[serde(rename = "defaultBranch")] 1463 pub default_branch: String, 1464} 1465 1466#[derive(Debug, Clone, Serialize, Deserialize)] 1467pub struct Workflow { 1468 pub name: String, 1469 pub engine: String, 1470} 1471 1472#[derive(Debug, Clone, Serialize, Deserialize)] 1473pub struct Pipeline { 1474 #[serde(rename = "triggerMetadata")] 1475 pub trigger_metadata: TriggerMetadata, 1476 pub workflows: Vec<Workflow>, 1477} 1478 1479#[derive(Debug, Clone)] 1480pub struct PipelineRecord { 1481 pub rkey: String, 1482 pub pipeline: Pipeline, 1483}