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