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