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