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