Rust CLI for tangled
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: true,
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: true,
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: true,
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: true,
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: true,
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: true,
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: true,
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 pub async fn list_repo_pulls(
991 &self,
992 repo_at: &str,
993 state: Option<&str>,
994 pds_base: &str,
995 access_jwt: &str,
996 ) -> Result<Vec<RepoPull>> {
997 let sa = self.service_auth_token(pds_base, access_jwt).await?;
998
999 #[derive(Deserialize)]
1000 struct Res {
1001 pulls: Vec<RepoPull>,
1002 }
1003
1004 let mut params = vec![("repo", repo_at.to_string())];
1005 if let Some(s) = state {
1006 params.push(("state", s.to_string()));
1007 }
1008
1009 let res: Res = self
1010 .get_json("sh.tangled.repo.listPulls", ¶ms, Some(&sa))
1011 .await?;
1012 Ok(res.pulls)
1013 }
1014
1015 #[allow(clippy::too_many_arguments)]
1016 pub async fn create_pull(
1017 &self,
1018 author_did: &str,
1019 repo_did: &str,
1020 repo_rkey: &str,
1021 target_branch: &str,
1022 patch: &str,
1023 title: &str,
1024 body: Option<&str>,
1025 pds_base: &str,
1026 access_jwt: &str,
1027 ) -> Result<String> {
1028 #[derive(Serialize)]
1029 struct Target<'a> {
1030 repo: &'a str,
1031 branch: &'a str,
1032 }
1033 #[derive(Serialize)]
1034 struct Rec<'a> {
1035 target: Target<'a>,
1036 title: &'a str,
1037 #[serde(skip_serializing_if = "Option::is_none")]
1038 body: Option<&'a str>,
1039 patch: &'a str,
1040 #[serde(rename = "createdAt")]
1041 created_at: String,
1042 }
1043 #[derive(Serialize)]
1044 struct Req<'a> {
1045 repo: &'a str,
1046 collection: &'a str,
1047 validate: bool,
1048 record: Rec<'a>,
1049 }
1050 #[derive(Deserialize)]
1051 struct Res {
1052 uri: String,
1053 }
1054 let repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey);
1055 let now = chrono::Utc::now().to_rfc3339();
1056 let rec = Rec {
1057 target: Target {
1058 repo: &repo_at,
1059 branch: target_branch,
1060 },
1061 title,
1062 body,
1063 patch,
1064 created_at: now,
1065 };
1066 let req = Req {
1067 repo: author_did,
1068 collection: "sh.tangled.repo.pull",
1069 validate: true,
1070 record: rec,
1071 };
1072 let pds_client = TangledClient::new(pds_base);
1073 let res: Res = pds_client
1074 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
1075 .await?;
1076 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull uri"))
1077 }
1078
1079 // ========== Spindle: Secrets Management ==========
1080 pub async fn list_repo_secrets(
1081 &self,
1082 pds_base: &str,
1083 access_jwt: &str,
1084 repo_at: &str,
1085 ) -> Result<Vec<Secret>> {
1086 let sa = self.service_auth_token(pds_base, access_jwt).await?;
1087 #[derive(Deserialize)]
1088 struct Res {
1089 secrets: Vec<Secret>,
1090 }
1091 let params = [("repo", repo_at.to_string())];
1092 let res: Res = self
1093 .get_json("sh.tangled.repo.listSecrets", ¶ms, Some(&sa))
1094 .await?;
1095 Ok(res.secrets)
1096 }
1097
1098 pub async fn add_repo_secret(
1099 &self,
1100 pds_base: &str,
1101 access_jwt: &str,
1102 repo_at: &str,
1103 key: &str,
1104 value: &str,
1105 ) -> Result<()> {
1106 let sa = self.service_auth_token(pds_base, access_jwt).await?;
1107 #[derive(Serialize)]
1108 struct Req<'a> {
1109 repo: &'a str,
1110 key: &'a str,
1111 value: &'a str,
1112 }
1113 let body = Req {
1114 repo: repo_at,
1115 key,
1116 value,
1117 };
1118 self.post("sh.tangled.repo.addSecret", &body, Some(&sa))
1119 .await
1120 }
1121
1122 pub async fn remove_repo_secret(
1123 &self,
1124 pds_base: &str,
1125 access_jwt: &str,
1126 repo_at: &str,
1127 key: &str,
1128 ) -> Result<()> {
1129 let sa = self.service_auth_token(pds_base, access_jwt).await?;
1130 #[derive(Serialize)]
1131 struct Req<'a> {
1132 repo: &'a str,
1133 key: &'a str,
1134 }
1135 let body = Req { repo: repo_at, key };
1136 self.post("sh.tangled.repo.removeSecret", &body, Some(&sa))
1137 .await
1138 }
1139
1140 async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> {
1141 let base_trimmed = self.base_url.trim_end_matches('/');
1142 let host = base_trimmed
1143 .strip_prefix("https://")
1144 .or_else(|| base_trimmed.strip_prefix("http://"))
1145 .unwrap_or(base_trimmed); // If no protocol, use the URL as-is
1146 let audience = format!("did:web:{}", host);
1147 #[derive(Deserialize)]
1148 struct GetSARes {
1149 token: String,
1150 }
1151 let pds = TangledClient::new(pds_base);
1152 // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec
1153 let params = [
1154 ("aud", audience),
1155 ("exp", (chrono::Utc::now().timestamp() + 60).to_string()),
1156 ];
1157 let sa: GetSARes = pds
1158 .get_json(
1159 "com.atproto.server.getServiceAuth",
1160 ¶ms,
1161 Some(access_jwt),
1162 )
1163 .await?;
1164 Ok(sa.token)
1165 }
1166
1167 pub async fn comment_pull(
1168 &self,
1169 author_did: &str,
1170 pull_at: &str,
1171 body: &str,
1172 pds_base: &str,
1173 access_jwt: &str,
1174 ) -> Result<String> {
1175 #[derive(Serialize)]
1176 struct Rec<'a> {
1177 pull: &'a str,
1178 body: &'a str,
1179 #[serde(rename = "createdAt")]
1180 created_at: String,
1181 }
1182 #[derive(Serialize)]
1183 struct Req<'a> {
1184 repo: &'a str,
1185 collection: &'a str,
1186 validate: bool,
1187 record: Rec<'a>,
1188 }
1189 #[derive(Deserialize)]
1190 struct Res {
1191 uri: String,
1192 }
1193 let now = chrono::Utc::now().to_rfc3339();
1194 let rec = Rec {
1195 pull: pull_at,
1196 body,
1197 created_at: now,
1198 };
1199 let req = Req {
1200 repo: author_did,
1201 collection: "sh.tangled.repo.pull.comment",
1202 validate: true,
1203 record: rec,
1204 };
1205 let pds_client = TangledClient::new(pds_base);
1206 let res: Res = pds_client
1207 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
1208 .await?;
1209 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri"))
1210 }
1211
1212 pub async fn merge_pull(
1213 &self,
1214 pull_did: &str,
1215 pull_rkey: &str,
1216 repo_did: &str,
1217 repo_name: &str,
1218 pds_base: &str,
1219 access_jwt: &str,
1220 ) -> Result<()> {
1221 // Fetch the pull request to get patch and target branch
1222 let pds_client = TangledClient::new(pds_base);
1223 let pull = pds_client
1224 .get_pull_record(pull_did, pull_rkey, Some(access_jwt))
1225 .await?;
1226
1227 // Get service auth token for the knot
1228 let sa = self.service_auth_token(pds_base, access_jwt).await?;
1229
1230 #[derive(Serialize)]
1231 struct MergeReq<'a> {
1232 did: &'a str,
1233 name: &'a str,
1234 patch: &'a str,
1235 branch: &'a str,
1236 #[serde(skip_serializing_if = "Option::is_none")]
1237 #[serde(rename = "commitMessage")]
1238 commit_message: Option<&'a str>,
1239 #[serde(skip_serializing_if = "Option::is_none")]
1240 #[serde(rename = "commitBody")]
1241 commit_body: Option<&'a str>,
1242 }
1243
1244 let commit_body = if pull.body.is_empty() {
1245 None
1246 } else {
1247 Some(pull.body.as_str())
1248 };
1249
1250 // For now, only patch-based PRs can be merged via CLI
1251 // Branch-based PRs need to be merged via the web interface
1252 let patch_str = pull.patch.as_deref()
1253 .ok_or_else(|| anyhow!("Cannot merge branch-based PR via CLI. Please use the web interface."))?;
1254
1255 let req = MergeReq {
1256 did: repo_did,
1257 name: repo_name,
1258 patch: patch_str,
1259 branch: &pull.target.branch,
1260 commit_message: Some(&pull.title),
1261 commit_body,
1262 };
1263
1264 let _: serde_json::Value = self
1265 .post_json("sh.tangled.repo.merge", &req, Some(&sa))
1266 .await?;
1267 Ok(())
1268 }
1269
1270 pub async fn update_repo_spindle(
1271 &self,
1272 did: &str,
1273 rkey: &str,
1274 new_spindle: Option<&str>,
1275 pds_base: &str,
1276 access_jwt: &str,
1277 ) -> Result<()> {
1278 let pds_client = TangledClient::new(pds_base);
1279 #[derive(Deserialize, Serialize, Clone)]
1280 struct Rec {
1281 name: String,
1282 knot: String,
1283 #[serde(skip_serializing_if = "Option::is_none")]
1284 description: Option<String>,
1285 #[serde(skip_serializing_if = "Option::is_none")]
1286 spindle: Option<String>,
1287 #[serde(rename = "createdAt")]
1288 created_at: String,
1289 }
1290 #[derive(Deserialize)]
1291 struct GetRes {
1292 value: Rec,
1293 }
1294 let params = [
1295 ("repo", did.to_string()),
1296 ("collection", "sh.tangled.repo".to_string()),
1297 ("rkey", rkey.to_string()),
1298 ];
1299 let got: GetRes = pds_client
1300 .get_json("com.atproto.repo.getRecord", ¶ms, Some(access_jwt))
1301 .await?;
1302 let mut rec = got.value;
1303 rec.spindle = new_spindle.map(|s| s.to_string());
1304 #[derive(Serialize)]
1305 struct PutReq<'a> {
1306 repo: &'a str,
1307 collection: &'a str,
1308 rkey: &'a str,
1309 validate: bool,
1310 record: Rec,
1311 }
1312 let req = PutReq {
1313 repo: did,
1314 collection: "sh.tangled.repo",
1315 rkey,
1316 validate: true,
1317 record: rec,
1318 };
1319 let _: serde_json::Value = pds_client
1320 .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt))
1321 .await?;
1322 Ok(())
1323 }
1324
1325 pub async fn list_pipelines(
1326 &self,
1327 repo_did: &str,
1328 bearer: Option<&str>,
1329 ) -> Result<Vec<PipelineRecord>> {
1330 #[derive(Deserialize)]
1331 struct Item {
1332 uri: String,
1333 value: Pipeline,
1334 }
1335 #[derive(Deserialize)]
1336 struct ListRes {
1337 #[serde(default)]
1338 records: Vec<Item>,
1339 }
1340 let params = vec![
1341 ("repo", repo_did.to_string()),
1342 ("collection", "sh.tangled.pipeline".to_string()),
1343 ("limit", "100".to_string()),
1344 ];
1345 let res: ListRes = self
1346 .get_json("com.atproto.repo.listRecords", ¶ms, bearer)
1347 .await?;
1348 let mut out = vec![];
1349 for it in res.records {
1350 let rkey = Self::uri_rkey(&it.uri).unwrap_or_default();
1351 out.push(PipelineRecord {
1352 rkey,
1353 pipeline: it.value,
1354 });
1355 }
1356 Ok(out)
1357 }
1358}
1359
1360#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1361pub struct Repository {
1362 pub did: Option<String>,
1363 pub rkey: Option<String>,
1364 pub name: String,
1365 pub knot: Option<String>,
1366 pub description: Option<String>,
1367 pub spindle: Option<String>,
1368 #[serde(default)]
1369 pub private: bool,
1370}
1371
1372// Issue record value
1373#[derive(Debug, Clone, Serialize, Deserialize)]
1374pub struct Issue {
1375 pub repo: String,
1376 pub title: String,
1377 #[serde(default)]
1378 pub body: String,
1379 #[serde(rename = "createdAt")]
1380 pub created_at: String,
1381}
1382
1383#[derive(Debug, Clone)]
1384pub struct IssueRecord {
1385 pub author_did: String,
1386 pub rkey: String,
1387 pub issue: Issue,
1388}
1389
1390// Pull record value (subset)
1391#[derive(Debug, Clone, Serialize, Deserialize)]
1392pub struct PullTarget {
1393 pub repo: String,
1394 pub branch: String,
1395}
1396
1397#[derive(Debug, Clone, Serialize, Deserialize)]
1398pub struct PullSource {
1399 pub sha: String,
1400 #[serde(default)]
1401 pub repo: Option<String>,
1402 #[serde(default)]
1403 pub branch: Option<String>,
1404}
1405
1406#[derive(Debug, Clone, Serialize, Deserialize)]
1407pub struct Pull {
1408 pub target: PullTarget,
1409 pub title: String,
1410 #[serde(default)]
1411 pub body: String,
1412 #[serde(default)]
1413 pub patch: Option<String>,
1414 #[serde(default)]
1415 pub source: Option<PullSource>,
1416 #[serde(rename = "createdAt")]
1417 pub created_at: String,
1418}
1419
1420#[derive(Debug, Clone)]
1421pub struct PullRecord {
1422 pub author_did: String,
1423 pub rkey: String,
1424 pub pull: Pull,
1425}
1426
1427#[derive(Debug, Clone, Deserialize)]
1428pub struct RepoPull {
1429 pub rkey: String,
1430 #[serde(rename = "ownerDid")]
1431 pub owner_did: String,
1432 #[serde(rename = "pullId")]
1433 pub pull_id: i32,
1434 pub title: String,
1435 pub state: i32,
1436 #[serde(rename = "targetBranch")]
1437 pub target_branch: String,
1438 #[serde(rename = "createdAt")]
1439 pub created_at: String,
1440}
1441
1442#[derive(Debug, Clone)]
1443pub struct RepoRecord {
1444 pub did: String,
1445 pub name: String,
1446 pub rkey: String,
1447 pub knot: String,
1448 pub description: Option<String>,
1449 pub spindle: Option<String>,
1450}
1451
1452#[derive(Debug, Clone, Serialize, Deserialize)]
1453pub struct DefaultBranch {
1454 pub name: String,
1455 pub hash: String,
1456 #[serde(skip_serializing_if = "Option::is_none")]
1457 pub short_hash: Option<String>,
1458 pub when: String,
1459 #[serde(skip_serializing_if = "Option::is_none")]
1460 pub message: Option<String>,
1461}
1462
1463#[derive(Debug, Clone, Serialize, Deserialize)]
1464pub struct Language {
1465 pub name: String,
1466 pub size: u64,
1467 pub percentage: u64,
1468}
1469
1470#[derive(Debug, Clone, Serialize, Deserialize)]
1471pub struct Languages {
1472 pub languages: Vec<Language>,
1473 #[serde(skip_serializing_if = "Option::is_none")]
1474 pub total_size: Option<u64>,
1475 #[serde(skip_serializing_if = "Option::is_none")]
1476 pub total_files: Option<u64>,
1477}
1478
1479#[derive(Debug, Clone, Serialize, Deserialize)]
1480pub struct StarRecord {
1481 pub subject: String,
1482 #[serde(rename = "createdAt")]
1483 pub created_at: String,
1484}
1485
1486#[derive(Debug, Clone, Serialize, Deserialize)]
1487pub struct Secret {
1488 pub repo: String,
1489 pub key: String,
1490 #[serde(rename = "createdAt")]
1491 pub created_at: String,
1492 #[serde(rename = "createdBy")]
1493 pub created_by: String,
1494}
1495
1496#[derive(Debug, Clone)]
1497pub struct CreateRepoOptions<'a> {
1498 pub did: &'a str,
1499 pub name: &'a str,
1500 pub knot: &'a str,
1501 pub description: Option<&'a str>,
1502 pub default_branch: Option<&'a str>,
1503 pub source: Option<&'a str>,
1504 pub pds_base: &'a str,
1505 pub access_jwt: &'a str,
1506}
1507
1508#[derive(Debug, Clone, Serialize, Deserialize)]
1509pub struct TriggerMetadata {
1510 pub kind: String,
1511 pub repo: TriggerRepo,
1512}
1513
1514#[derive(Debug, Clone, Serialize, Deserialize)]
1515pub struct TriggerRepo {
1516 pub knot: String,
1517 pub did: String,
1518 pub repo: String,
1519 #[serde(rename = "defaultBranch")]
1520 pub default_branch: String,
1521}
1522
1523#[derive(Debug, Clone, Serialize, Deserialize)]
1524pub struct Workflow {
1525 pub name: String,
1526 pub engine: String,
1527}
1528
1529#[derive(Debug, Clone, Serialize, Deserialize)]
1530pub struct Pipeline {
1531 #[serde(rename = "triggerMetadata")]
1532 pub trigger_metadata: TriggerMetadata,
1533 pub workflows: Vec<Workflow>,
1534}
1535
1536#[derive(Debug, Clone)]
1537pub struct PipelineRecord {
1538 pub rkey: String,
1539 pub pipeline: Pipeline,
1540}