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 #[allow(dead_code)] 709 cid: Option<String>, 710 value: Issue, 711 } 712 #[derive(Deserialize)] 713 struct ListRes { 714 #[serde(default)] 715 records: Vec<Item>, 716 } 717 let params = vec![ 718 ("repo", author_did.to_string()), 719 ("collection", "sh.tangled.repo.issue".to_string()), 720 ("limit", "100".to_string()), 721 ]; 722 let res: ListRes = self 723 .get_json("com.atproto.repo.listRecords", &params, bearer) 724 .await?; 725 let mut out = vec![]; 726 for it in res.records { 727 if let Some(filter_repo) = repo_at_uri { 728 if it.value.repo.as_str() != filter_repo { 729 continue; 730 } 731 } 732 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 733 out.push(IssueRecord { 734 author_did: author_did.to_string(), 735 rkey, 736 issue: it.value, 737 }); 738 } 739 Ok(out) 740 } 741 742 #[allow(clippy::too_many_arguments)] 743 pub async fn create_issue( 744 &self, 745 author_did: &str, 746 repo_did: &str, 747 repo_rkey: &str, 748 title: &str, 749 body: Option<&str>, 750 pds_base: &str, 751 access_jwt: &str, 752 ) -> Result<String> { 753 #[derive(Serialize)] 754 struct Rec<'a> { 755 repo: &'a str, 756 title: &'a str, 757 #[serde(skip_serializing_if = "Option::is_none")] 758 body: Option<&'a str>, 759 #[serde(rename = "createdAt")] 760 created_at: String, 761 } 762 #[derive(Serialize)] 763 struct Req<'a> { 764 repo: &'a str, 765 collection: &'a str, 766 validate: bool, 767 record: Rec<'a>, 768 } 769 #[derive(Deserialize)] 770 struct Res { 771 uri: String, 772 } 773 let issue_repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); 774 let now = chrono::Utc::now().to_rfc3339(); 775 let rec = Rec { 776 repo: &issue_repo_at, 777 title, 778 body, 779 created_at: now, 780 }; 781 let req = Req { 782 repo: author_did, 783 collection: "sh.tangled.repo.issue", 784 validate: false, 785 record: rec, 786 }; 787 let pds_client = TangledClient::new(pds_base); 788 let res: Res = pds_client 789 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 790 .await?; 791 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue uri")) 792 } 793 794 pub async fn comment_issue( 795 &self, 796 author_did: &str, 797 issue_at: &str, 798 body: &str, 799 pds_base: &str, 800 access_jwt: &str, 801 ) -> Result<String> { 802 #[derive(Serialize)] 803 struct Rec<'a> { 804 issue: &'a str, 805 body: &'a str, 806 #[serde(rename = "createdAt")] 807 created_at: String, 808 } 809 #[derive(Serialize)] 810 struct Req<'a> { 811 repo: &'a str, 812 collection: &'a str, 813 validate: bool, 814 record: Rec<'a>, 815 } 816 #[derive(Deserialize)] 817 struct Res { 818 uri: String, 819 } 820 let now = chrono::Utc::now().to_rfc3339(); 821 let rec = Rec { 822 issue: issue_at, 823 body, 824 created_at: now, 825 }; 826 let req = Req { 827 repo: author_did, 828 collection: "sh.tangled.repo.issue.comment", 829 validate: false, 830 record: rec, 831 }; 832 let pds_client = TangledClient::new(pds_base); 833 let res: Res = pds_client 834 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 835 .await?; 836 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue comment uri")) 837 } 838 839 pub async fn get_issue_record( 840 &self, 841 author_did: &str, 842 rkey: &str, 843 bearer: Option<&str>, 844 ) -> Result<Issue> { 845 #[derive(Deserialize)] 846 struct GetRes { 847 value: Issue, 848 } 849 let params = [ 850 ("repo", author_did.to_string()), 851 ("collection", "sh.tangled.repo.issue".to_string()), 852 ("rkey", rkey.to_string()), 853 ]; 854 let res: GetRes = self 855 .get_json("com.atproto.repo.getRecord", &params, bearer) 856 .await?; 857 Ok(res.value) 858 } 859 860 pub async fn put_issue_record( 861 &self, 862 author_did: &str, 863 rkey: &str, 864 record: &Issue, 865 bearer: Option<&str>, 866 ) -> Result<()> { 867 #[derive(Serialize)] 868 struct PutReq<'a> { 869 repo: &'a str, 870 collection: &'a str, 871 rkey: &'a str, 872 validate: bool, 873 record: &'a Issue, 874 } 875 let req = PutReq { 876 repo: author_did, 877 collection: "sh.tangled.repo.issue", 878 rkey, 879 validate: false, 880 record, 881 }; 882 let _: serde_json::Value = self 883 .post_json("com.atproto.repo.putRecord", &req, bearer) 884 .await?; 885 Ok(()) 886 } 887 888 pub async fn set_issue_state( 889 &self, 890 author_did: &str, 891 issue_at: &str, 892 state_nsid: &str, 893 pds_base: &str, 894 access_jwt: &str, 895 ) -> Result<String> { 896 #[derive(Serialize)] 897 struct Rec<'a> { 898 issue: &'a str, 899 state: &'a str, 900 } 901 #[derive(Serialize)] 902 struct Req<'a> { 903 repo: &'a str, 904 collection: &'a str, 905 validate: bool, 906 record: Rec<'a>, 907 } 908 #[derive(Deserialize)] 909 struct Res { 910 uri: String, 911 } 912 let rec = Rec { 913 issue: issue_at, 914 state: state_nsid, 915 }; 916 let req = Req { 917 repo: author_did, 918 collection: "sh.tangled.repo.issue.state", 919 validate: false, 920 record: rec, 921 }; 922 let pds_client = TangledClient::new(pds_base); 923 let res: Res = pds_client 924 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 925 .await?; 926 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri")) 927 } 928 929 pub async fn list_issue_states( 930 &self, 931 author_did: &str, 932 bearer: Option<&str>, 933 ) -> Result<Vec<IssueState>> { 934 #[derive(Deserialize)] 935 struct Item { 936 #[allow(dead_code)] 937 uri: String, 938 #[allow(dead_code)] 939 cid: Option<String>, 940 value: IssueState, 941 } 942 #[derive(Deserialize)] 943 struct ListRes { 944 #[serde(default)] 945 records: Vec<Item>, 946 } 947 let params = vec![ 948 ("repo", author_did.to_string()), 949 ("collection", "sh.tangled.repo.issue.state".to_string()), 950 ("limit", "100".to_string()), 951 ]; 952 let res: ListRes = self 953 .get_json("com.atproto.repo.listRecords", &params, bearer) 954 .await?; 955 Ok(res.records.into_iter().map(|it| it.value).collect()) 956 } 957 958 pub async fn get_pull_record( 959 &self, 960 author_did: &str, 961 rkey: &str, 962 bearer: Option<&str>, 963 ) -> Result<Pull> { 964 #[derive(Deserialize)] 965 struct GetRes { 966 value: Pull, 967 } 968 let params = [ 969 ("repo", author_did.to_string()), 970 ("collection", "sh.tangled.repo.pull".to_string()), 971 ("rkey", rkey.to_string()), 972 ]; 973 let res: GetRes = self 974 .get_json("com.atproto.repo.getRecord", &params, bearer) 975 .await?; 976 Ok(res.value) 977 } 978 979 // ========== Pull Requests ========== 980 pub async fn list_pulls( 981 &self, 982 author_did: &str, 983 target_repo_at_uri: Option<&str>, 984 bearer: Option<&str>, 985 ) -> Result<Vec<PullRecord>> { 986 #[derive(Deserialize)] 987 struct Item { 988 uri: String, 989 value: Pull, 990 } 991 #[derive(Deserialize)] 992 struct ListRes { 993 #[serde(default)] 994 records: Vec<Item>, 995 } 996 let params = vec![ 997 ("repo", author_did.to_string()), 998 ("collection", "sh.tangled.repo.pull".to_string()), 999 ("limit", "100".to_string()), 1000 ]; 1001 let res: ListRes = self 1002 .get_json("com.atproto.repo.listRecords", &params, bearer) 1003 .await?; 1004 let mut out = vec![]; 1005 for it in res.records { 1006 if let Some(target) = target_repo_at_uri { 1007 if it.value.target.repo.as_str() != target { 1008 continue; 1009 } 1010 } 1011 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 1012 out.push(PullRecord { 1013 author_did: author_did.to_string(), 1014 rkey, 1015 pull: it.value, 1016 }); 1017 } 1018 Ok(out) 1019 } 1020 1021 #[allow(clippy::too_many_arguments)] 1022 pub async fn create_pull( 1023 &self, 1024 author_did: &str, 1025 repo_did: &str, 1026 repo_rkey: &str, 1027 target_branch: &str, 1028 patch: &str, 1029 title: &str, 1030 body: Option<&str>, 1031 pds_base: &str, 1032 access_jwt: &str, 1033 ) -> Result<String> { 1034 #[derive(Serialize)] 1035 struct Target<'a> { 1036 repo: &'a str, 1037 branch: &'a str, 1038 } 1039 #[derive(Serialize)] 1040 struct Rec<'a> { 1041 target: Target<'a>, 1042 title: &'a str, 1043 #[serde(skip_serializing_if = "Option::is_none")] 1044 body: Option<&'a str>, 1045 patch: &'a str, 1046 #[serde(rename = "createdAt")] 1047 created_at: String, 1048 } 1049 #[derive(Serialize)] 1050 struct Req<'a> { 1051 repo: &'a str, 1052 collection: &'a str, 1053 validate: bool, 1054 record: Rec<'a>, 1055 } 1056 #[derive(Deserialize)] 1057 struct Res { 1058 uri: String, 1059 } 1060 let repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); 1061 let now = chrono::Utc::now().to_rfc3339(); 1062 let rec = Rec { 1063 target: Target { 1064 repo: &repo_at, 1065 branch: target_branch, 1066 }, 1067 title, 1068 body, 1069 patch, 1070 created_at: now, 1071 }; 1072 let req = Req { 1073 repo: author_did, 1074 collection: "sh.tangled.repo.pull", 1075 validate: false, 1076 record: rec, 1077 }; 1078 let pds_client = TangledClient::new(pds_base); 1079 let res: Res = pds_client 1080 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1081 .await?; 1082 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull uri")) 1083 } 1084 1085 // ========== Spindle: Secrets Management ========== 1086 pub async fn list_repo_secrets( 1087 &self, 1088 pds_base: &str, 1089 access_jwt: &str, 1090 repo_at: &str, 1091 ) -> Result<Vec<Secret>> { 1092 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1093 #[derive(Deserialize)] 1094 struct Res { 1095 secrets: Vec<Secret>, 1096 } 1097 let params = [("repo", repo_at.to_string())]; 1098 let res: Res = self 1099 .get_json("sh.tangled.repo.listSecrets", &params, Some(&sa)) 1100 .await?; 1101 Ok(res.secrets) 1102 } 1103 1104 pub async fn add_repo_secret( 1105 &self, 1106 pds_base: &str, 1107 access_jwt: &str, 1108 repo_at: &str, 1109 key: &str, 1110 value: &str, 1111 ) -> Result<()> { 1112 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1113 #[derive(Serialize)] 1114 struct Req<'a> { 1115 repo: &'a str, 1116 key: &'a str, 1117 value: &'a str, 1118 } 1119 let body = Req { 1120 repo: repo_at, 1121 key, 1122 value, 1123 }; 1124 self.post("sh.tangled.repo.addSecret", &body, Some(&sa)) 1125 .await 1126 } 1127 1128 pub async fn remove_repo_secret( 1129 &self, 1130 pds_base: &str, 1131 access_jwt: &str, 1132 repo_at: &str, 1133 key: &str, 1134 ) -> Result<()> { 1135 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1136 #[derive(Serialize)] 1137 struct Req<'a> { 1138 repo: &'a str, 1139 key: &'a str, 1140 } 1141 let body = Req { repo: repo_at, key }; 1142 self.post("sh.tangled.repo.removeSecret", &body, Some(&sa)) 1143 .await 1144 } 1145 1146 async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> { 1147 let base_trimmed = self.base_url.trim_end_matches('/'); 1148 let host = base_trimmed 1149 .strip_prefix("https://") 1150 .or_else(|| base_trimmed.strip_prefix("http://")) 1151 .unwrap_or(base_trimmed); // If no protocol, use the URL as-is 1152 let audience = format!("did:web:{}", host); 1153 #[derive(Deserialize)] 1154 struct GetSARes { 1155 token: String, 1156 } 1157 let pds = TangledClient::new(pds_base); 1158 // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 1159 let params = [ 1160 ("aud", audience), 1161 ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 1162 ]; 1163 let sa: GetSARes = pds 1164 .get_json( 1165 "com.atproto.server.getServiceAuth", 1166 &params, 1167 Some(access_jwt), 1168 ) 1169 .await?; 1170 Ok(sa.token) 1171 } 1172 1173 pub async fn comment_pull( 1174 &self, 1175 author_did: &str, 1176 pull_at: &str, 1177 body: &str, 1178 pds_base: &str, 1179 access_jwt: &str, 1180 ) -> Result<String> { 1181 #[derive(Serialize)] 1182 struct Rec<'a> { 1183 pull: &'a str, 1184 body: &'a str, 1185 #[serde(rename = "createdAt")] 1186 created_at: String, 1187 } 1188 #[derive(Serialize)] 1189 struct Req<'a> { 1190 repo: &'a str, 1191 collection: &'a str, 1192 validate: bool, 1193 record: Rec<'a>, 1194 } 1195 #[derive(Deserialize)] 1196 struct Res { 1197 uri: String, 1198 } 1199 let now = chrono::Utc::now().to_rfc3339(); 1200 let rec = Rec { 1201 pull: pull_at, 1202 body, 1203 created_at: now, 1204 }; 1205 let req = Req { 1206 repo: author_did, 1207 collection: "sh.tangled.repo.pull.comment", 1208 validate: false, 1209 record: rec, 1210 }; 1211 let pds_client = TangledClient::new(pds_base); 1212 let res: Res = pds_client 1213 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1214 .await?; 1215 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri")) 1216 } 1217 1218 pub async fn merge_pull( 1219 &self, 1220 pull_did: &str, 1221 pull_rkey: &str, 1222 repo_did: &str, 1223 repo_name: &str, 1224 pds_base: &str, 1225 access_jwt: &str, 1226 ) -> Result<()> { 1227 // Fetch the pull request to get patch and target branch 1228 let pds_client = TangledClient::new(pds_base); 1229 let pull = pds_client 1230 .get_pull_record(pull_did, pull_rkey, Some(access_jwt)) 1231 .await?; 1232 1233 // Get service auth token for the knot 1234 let sa = self.service_auth_token(pds_base, access_jwt).await?; 1235 1236 #[derive(Serialize)] 1237 struct MergeReq<'a> { 1238 did: &'a str, 1239 name: &'a str, 1240 patch: &'a str, 1241 branch: &'a str, 1242 #[serde(skip_serializing_if = "Option::is_none")] 1243 #[serde(rename = "commitMessage")] 1244 commit_message: Option<&'a str>, 1245 #[serde(skip_serializing_if = "Option::is_none")] 1246 #[serde(rename = "commitBody")] 1247 commit_body: Option<&'a str>, 1248 } 1249 1250 let commit_body = if pull.body.is_empty() { 1251 None 1252 } else { 1253 Some(pull.body.as_str()) 1254 }; 1255 1256 let req = MergeReq { 1257 did: repo_did, 1258 name: repo_name, 1259 patch: &pull.patch, 1260 branch: &pull.target.branch, 1261 commit_message: Some(&pull.title), 1262 commit_body, 1263 }; 1264 1265 let _: serde_json::Value = self 1266 .post_json("sh.tangled.repo.merge", &req, Some(&sa)) 1267 .await?; 1268 Ok(()) 1269 } 1270 1271 pub async fn update_repo_spindle( 1272 &self, 1273 did: &str, 1274 rkey: &str, 1275 new_spindle: Option<&str>, 1276 pds_base: &str, 1277 access_jwt: &str, 1278 ) -> Result<()> { 1279 let pds_client = TangledClient::new(pds_base); 1280 #[derive(Deserialize, Serialize, Clone)] 1281 struct Rec { 1282 name: String, 1283 knot: String, 1284 #[serde(skip_serializing_if = "Option::is_none")] 1285 description: Option<String>, 1286 #[serde(skip_serializing_if = "Option::is_none")] 1287 spindle: Option<String>, 1288 #[serde(rename = "createdAt")] 1289 created_at: String, 1290 } 1291 #[derive(Deserialize)] 1292 struct GetRes { 1293 value: Rec, 1294 } 1295 let params = [ 1296 ("repo", did.to_string()), 1297 ("collection", "sh.tangled.repo".to_string()), 1298 ("rkey", rkey.to_string()), 1299 ]; 1300 let got: GetRes = pds_client 1301 .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 1302 .await?; 1303 let mut rec = got.value; 1304 rec.spindle = new_spindle.map(|s| s.to_string()); 1305 #[derive(Serialize)] 1306 struct PutReq<'a> { 1307 repo: &'a str, 1308 collection: &'a str, 1309 rkey: &'a str, 1310 validate: bool, 1311 record: Rec, 1312 } 1313 let req = PutReq { 1314 repo: did, 1315 collection: "sh.tangled.repo", 1316 rkey, 1317 validate: false, 1318 record: rec, 1319 }; 1320 let _: serde_json::Value = pds_client 1321 .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 1322 .await?; 1323 Ok(()) 1324 } 1325 1326 pub async fn list_pipelines( 1327 &self, 1328 repo_did: &str, 1329 bearer: Option<&str>, 1330 ) -> Result<Vec<PipelineRecord>> { 1331 #[derive(Deserialize)] 1332 struct Item { 1333 uri: String, 1334 value: Pipeline, 1335 } 1336 #[derive(Deserialize)] 1337 struct ListRes { 1338 #[serde(default)] 1339 records: Vec<Item>, 1340 } 1341 let params = vec![ 1342 ("repo", repo_did.to_string()), 1343 ("collection", "sh.tangled.pipeline".to_string()), 1344 ("limit", "100".to_string()), 1345 ]; 1346 let res: ListRes = self 1347 .get_json("com.atproto.repo.listRecords", &params, bearer) 1348 .await?; 1349 let mut out = vec![]; 1350 for it in res.records { 1351 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 1352 out.push(PipelineRecord { 1353 rkey, 1354 pipeline: it.value, 1355 }); 1356 } 1357 Ok(out) 1358 } 1359} 1360 1361#[derive(Debug, Clone, Serialize, Deserialize, Default)] 1362pub struct Repository { 1363 pub did: Option<String>, 1364 pub rkey: Option<String>, 1365 pub name: String, 1366 pub knot: Option<String>, 1367 pub description: Option<String>, 1368 pub spindle: Option<String>, 1369 #[serde(default)] 1370 pub private: bool, 1371} 1372 1373// Issue record value 1374#[derive(Debug, Clone, Serialize, Deserialize)] 1375pub struct Issue { 1376 pub repo: String, 1377 pub title: String, 1378 #[serde(default)] 1379 pub body: String, 1380 #[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")] 1381 pub created_at: Option<String>, 1382 #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 1383 pub record_type: Option<String>, 1384 #[serde(skip_serializing_if = "Option::is_none")] 1385 pub owner: Option<String>, 1386 #[serde(rename = "issueId", skip_serializing_if = "Option::is_none")] 1387 pub issue_id: Option<i64>, 1388} 1389 1390#[derive(Debug, Clone)] 1391pub struct IssueRecord { 1392 pub author_did: String, 1393 pub rkey: String, 1394 pub issue: Issue, 1395} 1396 1397#[derive(Debug, Clone, Serialize, Deserialize)] 1398pub struct IssueState { 1399 pub issue: String, 1400 pub state: String, 1401} 1402 1403// Pull record value (subset) 1404#[derive(Debug, Clone, Serialize, Deserialize)] 1405pub struct PullTarget { 1406 pub repo: String, 1407 pub branch: String, 1408} 1409 1410#[derive(Debug, Clone, Serialize, Deserialize)] 1411pub struct Pull { 1412 pub target: PullTarget, 1413 pub title: String, 1414 #[serde(default)] 1415 pub body: String, 1416 pub patch: String, 1417 #[serde(rename = "createdAt")] 1418 pub created_at: String, 1419} 1420 1421#[derive(Debug, Clone)] 1422pub struct PullRecord { 1423 pub author_did: String, 1424 pub rkey: String, 1425 pub pull: Pull, 1426} 1427 1428#[derive(Debug, Clone)] 1429pub struct RepoRecord { 1430 pub did: String, 1431 pub name: String, 1432 pub rkey: String, 1433 pub knot: String, 1434 pub description: Option<String>, 1435 pub spindle: Option<String>, 1436} 1437 1438#[derive(Debug, Clone, Serialize, Deserialize)] 1439pub struct DefaultBranch { 1440 pub name: String, 1441 pub hash: String, 1442 #[serde(skip_serializing_if = "Option::is_none")] 1443 pub short_hash: Option<String>, 1444 pub when: String, 1445 #[serde(skip_serializing_if = "Option::is_none")] 1446 pub message: Option<String>, 1447} 1448 1449#[derive(Debug, Clone, Serialize, Deserialize)] 1450pub struct Language { 1451 pub name: String, 1452 pub size: u64, 1453 pub percentage: u64, 1454} 1455 1456#[derive(Debug, Clone, Serialize, Deserialize)] 1457pub struct Languages { 1458 pub languages: Vec<Language>, 1459 #[serde(skip_serializing_if = "Option::is_none")] 1460 pub total_size: Option<u64>, 1461 #[serde(skip_serializing_if = "Option::is_none")] 1462 pub total_files: Option<u64>, 1463} 1464 1465#[derive(Debug, Clone, Serialize, Deserialize)] 1466pub struct StarRecord { 1467 pub subject: String, 1468 #[serde(rename = "createdAt")] 1469 pub created_at: String, 1470} 1471 1472#[derive(Debug, Clone, Serialize, Deserialize)] 1473pub struct Secret { 1474 pub repo: String, 1475 pub key: String, 1476 #[serde(rename = "createdAt")] 1477 pub created_at: String, 1478 #[serde(rename = "createdBy")] 1479 pub created_by: String, 1480} 1481 1482#[derive(Debug, Clone)] 1483pub struct CreateRepoOptions<'a> { 1484 pub did: &'a str, 1485 pub name: &'a str, 1486 pub knot: &'a str, 1487 pub description: Option<&'a str>, 1488 pub default_branch: Option<&'a str>, 1489 pub source: Option<&'a str>, 1490 pub pds_base: &'a str, 1491 pub access_jwt: &'a str, 1492} 1493 1494#[derive(Debug, Clone, Serialize, Deserialize)] 1495pub struct TriggerMetadata { 1496 pub kind: String, 1497 pub repo: TriggerRepo, 1498} 1499 1500#[derive(Debug, Clone, Serialize, Deserialize)] 1501pub struct TriggerRepo { 1502 pub knot: String, 1503 pub did: String, 1504 pub repo: String, 1505 #[serde(rename = "defaultBranch")] 1506 pub default_branch: String, 1507} 1508 1509#[derive(Debug, Clone, Serialize, Deserialize)] 1510pub struct Workflow { 1511 pub name: String, 1512 pub engine: String, 1513} 1514 1515#[derive(Debug, Clone, Serialize, Deserialize)] 1516pub struct Pipeline { 1517 #[serde(rename = "triggerMetadata")] 1518 pub trigger_metadata: TriggerMetadata, 1519 pub workflows: Vec<Workflow>, 1520} 1521 1522#[derive(Debug, Clone)] 1523pub struct PipelineRecord { 1524 pub rkey: String, 1525 pub pipeline: Pipeline, 1526}