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(¶ms)
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", ¶ms, 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", ¶ms, 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 ¶ms,
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", ¶ms, 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", ¶ms, 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 ¶ms,
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", ¶ms, 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", ¶ms, 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", ¶ms, 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", ¶ms, 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", ¶ms, 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", ¶ms, 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", ¶ms, 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", ¶ms, 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", ¶ms, 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", ¶ms, 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 ¶ms,
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", ¶ms, 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", ¶ms, 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}