Something went wrong. Try again.
Rust CLI for tangled
Something went wrong. Try again.
37 kB · 1244 lines
Rust
at pds-warn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245use anyhow::{anyhow, Result};use serde::{de::DeserializeOwned, Deserialize, Serialize};use tangled_config::session::Session;
#[derive(Clone, Debug)]pub struct TangledClient { base_url: String,}
const REPO_CREATE: &str = "sh.tangled.repo.create";
impl Default for TangledClient { fn default() -> Self { Self::new("https://tngl.sh") }}
impl TangledClient { pub fn new(base_url: impl Into<String>) -> Self { Self { base_url: base_url.into(), } }
fn xrpc_url(&self, method: &str) -> String { format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method) }
async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( &self, method: &str, req: &TReq, bearer: Option<&str>, ) -> Result<TRes> { let url = self.xrpc_url(method); let client = reqwest::Client::new(); let mut reqb = client .post(url) .header(reqwest::header::CONTENT_TYPE, "application/json"); if let Some(token) = bearer { reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); } let res = reqb.json(req).send().await?; let status = res.status(); if !status.is_success() { let body = res.text().await.unwrap_or_default(); return Err(anyhow!("{}: {}", status, body)); } Ok(res.json::<TRes>().await?) }
async fn get_json<TRes: DeserializeOwned>( &self, method: &str, params: &[(&str, String)], bearer: Option<&str>, ) -> Result<TRes> { let url = self.xrpc_url(method); let client = reqwest::Client::new(); let mut reqb = client .get(&url) .query(¶ms) .header(reqwest::header::ACCEPT, "application/json"); if let Some(token) = bearer { reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); } let res = reqb.send().await?; let status = res.status(); let body = res.text().await.unwrap_or_default(); if !status.is_success() { return Err(anyhow!("GET {} -> {}: {}", url, status, body)); } serde_json::from_str::<TRes>(&body).map_err(|e| { let snippet = body.chars().take(300).collect::<String>(); anyhow!( "error decoding response from {}: {}\nBody (first 300 chars): {}", url, e, snippet ) }) }
pub async fn login_with_password( &self, handle: &str, password: &str, _pds: &str, ) -> Result<Session> { #[derive(Serialize)] struct Req<'a> { #[serde(rename = "identifier")] identifier: &'a str, #[serde(rename = "password")] password: &'a str, } #[derive(Deserialize)] struct Res { #[serde(rename = "accessJwt")] access_jwt: String, #[serde(rename = "refreshJwt")] refresh_jwt: String, did: String, handle: String, } let body = Req { identifier: handle, password, }; let res: Res = self .post_json("com.atproto.server.createSession", &body, None) .await?; Ok(Session { access_jwt: res.access_jwt, refresh_jwt: res.refresh_jwt, did: res.did, handle: res.handle, ..Default::default() }) }
pub async fn list_repos( &self, user: Option<&str>, knot: Option<&str>, starred: bool, bearer: Option<&str>, ) -> Result<Vec<Repository>> { // NOTE: Repo listing is done via the user's PDS using com.atproto.repo.listRecords // for the collection "sh.tangled.repo". This does not go through the Tangled API base. // Here, `self.base_url` must be the PDS base (e.g., https://bsky.social). // Resolve handle to DID if needed let did = match user { Some(u) if u.starts_with("did:") => u.to_string(), Some(handle) => { #[derive(Deserialize)] struct Res { did: String, } let params = [("handle", handle.to_string())]; let res: Res = self .get_json("com.atproto.identity.resolveHandle", ¶ms, bearer) .await?; res.did } None => { return Err(anyhow!( "missing user for list_repos; provide handle or DID" )); } };
#[derive(Deserialize)] struct RecordItem { uri: String, value: Repository, } #[derive(Deserialize)] struct ListRes { #[serde(default)] records: Vec<RecordItem>, }
let params = vec![ ("repo", did), ("collection", "sh.tangled.repo".to_string()), ("limit", "100".to_string()), ];
let res: ListRes = self .get_json("com.atproto.repo.listRecords", ¶ms, bearer) .await?; let mut repos: Vec<Repository> = res .records .into_iter() .map(|r| { let mut val = r.value; if val.rkey.is_none() { if let Some(k) = Self::uri_rkey(&r.uri) { val.rkey = Some(k); } } if val.did.is_none() { if let Some(d) = Self::uri_did(&r.uri) { val.did = Some(d); } } val }) .collect(); // Apply optional filters client-side if let Some(k) = knot { repos.retain(|r| r.knot.as_deref().unwrap_or("") == k); } if starred { // TODO: implement starred filtering when API is available. For now, no-op. } Ok(repos) }
pub async fn create_repo(&self, opts: CreateRepoOptions<'_>) -> Result<()> { // 1) Create the sh.tangled.repo record on the user's PDS #[derive(Serialize)] struct Record<'a> { name: &'a str, knot: &'a str, #[serde(skip_serializing_if = "Option::is_none")] description: Option<&'a str>, #[serde(rename = "createdAt")] created_at: String, } #[derive(Serialize)] struct CreateRecordReq<'a> { repo: &'a str, collection: &'a str, validate: bool, record: Record<'a>, } #[derive(Deserialize)] struct CreateRecordRes { uri: String, }
let now = chrono::Utc::now().to_rfc3339(); let rec = Record { name: opts.name, knot: opts.knot, description: opts.description, created_at: now, }; let create_req = CreateRecordReq { repo: opts.did, collection: "sh.tangled.repo", validate: true, record: rec, };
let pds_client = TangledClient::new(opts.pds_base); let created: CreateRecordRes = pds_client .post_json( "com.atproto.repo.createRecord", &create_req, Some(opts.access_jwt), ) .await?;
// Extract rkey from at-uri: at://did/collection/rkey let rkey = created .uri .rsplit('/') .next() .ok_or_else(|| anyhow!("failed to parse rkey from uri"))?;
// 2) Obtain a service auth token for the Tangled server (aud = did:web:<host>) let host = self .base_url .trim_end_matches('/') .strip_prefix("https://") .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) .ok_or_else(|| anyhow!("invalid base_url"))?; let audience = format!("did:web:{}", host);
#[derive(Deserialize)] struct GetSARes { token: String, } let params = [ ("aud", audience), ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), ]; let sa: GetSARes = pds_client .get_json( "com.atproto.server.getServiceAuth", ¶ms, Some(opts.access_jwt), ) .await?;
// 3) Call sh.tangled.repo.create with the rkey #[derive(Serialize)] struct CreateRepoReq<'a> { rkey: &'a str, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "defaultBranch")] default_branch: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] source: Option<&'a str>, } let req = CreateRepoReq { rkey, default_branch: opts.default_branch, source: opts.source, }; // No output expected on success let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?; Ok(()) }
pub async fn get_repo_info( &self, owner: &str, name: &str, bearer: Option<&str>, ) -> Result<RepoRecord> { let did = if owner.starts_with("did:") { owner.to_string() } else { #[derive(Deserialize)] struct Res { did: String, } let params = [("handle", owner.to_string())]; let res: Res = self .get_json("com.atproto.identity.resolveHandle", ¶ms, bearer) .await?; res.did };
#[derive(Deserialize)] struct RecordItem { uri: String, value: Repository, } #[derive(Deserialize)] struct ListRes { #[serde(default)] records: Vec<RecordItem>, } let params = vec![ ("repo", did.clone()), ("collection", "sh.tangled.repo".to_string()), ("limit", "100".to_string()), ]; let res: ListRes = self .get_json("com.atproto.repo.listRecords", ¶ms, bearer) .await?; for item in res.records { if item.value.name == name { let rkey = Self::uri_rkey(&item.uri).ok_or_else(|| anyhow!("missing rkey in uri"))?; let knot = item.value.knot.unwrap_or_default(); return Ok(RepoRecord { did: did.clone(), name: name.to_string(), rkey, knot, description: item.value.description, }); } } Err(anyhow!("repo not found for owner/name")) }
pub async fn delete_repo( &self, did: &str, name: &str, pds_base: &str, access_jwt: &str, ) -> Result<()> { let pds_client = TangledClient::new(pds_base); let info = pds_client .get_repo_info(did, name, Some(access_jwt)) .await?;
#[derive(Serialize)] struct DeleteRecordReq<'a> { repo: &'a str, collection: &'a str, rkey: &'a str, } let del = DeleteRecordReq { repo: did, collection: "sh.tangled.repo", rkey: &info.rkey, }; let _: serde_json::Value = pds_client .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) .await?;
let host = self .base_url .trim_end_matches('/') .strip_prefix("https://") .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) .ok_or_else(|| anyhow!("invalid base_url"))?; let audience = format!("did:web:{}", host); #[derive(Deserialize)] struct GetSARes { token: String, } let params = [ ("aud", audience), ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), ]; let sa: GetSARes = pds_client .get_json( "com.atproto.server.getServiceAuth", ¶ms, Some(access_jwt), ) .await?;
#[derive(Serialize)] struct DeleteReq<'a> { did: &'a str, name: &'a str, rkey: &'a str, } let body = DeleteReq { did, name, rkey: &info.rkey, }; let _: serde_json::Value = self .post_json("sh.tangled.repo.delete", &body, Some(&sa.token)) .await?; Ok(()) }
pub async fn update_repo_knot( &self, did: &str, rkey: &str, new_knot: &str, pds_base: &str, access_jwt: &str, ) -> Result<()> { let pds_client = TangledClient::new(pds_base); #[derive(Deserialize, Serialize, Clone)] struct Rec { name: String, knot: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option<String>, #[serde(rename = "createdAt")] created_at: String, } #[derive(Deserialize)] struct GetRes { value: Rec, } let params = [ ("repo", did.to_string()), ("collection", "sh.tangled.repo".to_string()), ("rkey", rkey.to_string()), ]; let got: GetRes = pds_client .get_json("com.atproto.repo.getRecord", ¶ms, Some(access_jwt)) .await?; let mut rec = got.value; rec.knot = new_knot.to_string(); #[derive(Serialize)] struct PutReq<'a> { repo: &'a str, collection: &'a str, rkey: &'a str, validate: bool, record: Rec, } let req = PutReq { repo: did, collection: "sh.tangled.repo", rkey, validate: true, record: rec, }; let _: serde_json::Value = pds_client .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) .await?; Ok(()) }
pub async fn get_default_branch( &self, knot_host: &str, did: &str, name: &str, ) -> Result<DefaultBranch> { #[derive(Deserialize)] struct Res { name: String, hash: String, #[serde(rename = "shortHash")] short_hash: Option<String>, when: String, message: Option<String>, } let knot_client = TangledClient::new(knot_host); let repo_param = format!("{}/{}", did, name); let params = [("repo", repo_param)]; let res: Res = knot_client .get_json("sh.tangled.repo.getDefaultBranch", ¶ms, None) .await?; Ok(DefaultBranch { name: res.name, hash: res.hash, short_hash: res.short_hash, when: res.when, message: res.message, }) }
pub async fn get_languages(&self, knot_host: &str, did: &str, name: &str) -> Result<Languages> { let knot_client = TangledClient::new(knot_host); let repo_param = format!("{}/{}", did, name); let params = [("repo", repo_param)]; let res: serde_json::Value = knot_client .get_json("sh.tangled.repo.languages", ¶ms, None) .await?; let langs = res .get("languages") .cloned() .unwrap_or(serde_json::json!([])); let languages: Vec<Language> = serde_json::from_value(langs)?; let total_size = res.get("totalSize").and_then(|v| v.as_u64()); let total_files = res.get("totalFiles").and_then(|v| v.as_u64()); Ok(Languages { languages, total_size, total_files, }) }
pub async fn star_repo( &self, pds_base: &str, access_jwt: &str, subject_at_uri: &str, user_did: &str, ) -> Result<String> { #[derive(Serialize)] struct Rec<'a> { subject: &'a str, #[serde(rename = "createdAt")] created_at: String, } #[derive(Serialize)] struct Req<'a> { repo: &'a str, collection: &'a str, validate: bool, record: Rec<'a>, } #[derive(Deserialize)] struct Res { uri: String, } let now = chrono::Utc::now().to_rfc3339(); let rec = Rec { subject: subject_at_uri, created_at: now, }; let req = Req { repo: user_did, collection: "sh.tangled.feed.star", validate: true, record: rec, }; let pds_client = TangledClient::new(pds_base); let res: Res = pds_client .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) .await?; let rkey = Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in star uri"))?; Ok(rkey) }
pub async fn unstar_repo( &self, pds_base: &str, access_jwt: &str, subject_at_uri: &str, user_did: &str, ) -> Result<()> { #[derive(Deserialize)] struct Item { uri: String, value: StarRecord, } #[derive(Deserialize)] struct ListRes { #[serde(default)] records: Vec<Item>, } let pds_client = TangledClient::new(pds_base); let params = vec![ ("repo", user_did.to_string()), ("collection", "sh.tangled.feed.star".to_string()), ("limit", "100".to_string()), ]; let res: ListRes = pds_client .get_json("com.atproto.repo.listRecords", ¶ms, Some(access_jwt)) .await?; let mut rkey = None; for item in res.records { if item.value.subject == subject_at_uri { rkey = Self::uri_rkey(&item.uri); if rkey.is_some() { break; } } } let rkey = rkey.ok_or_else(|| anyhow!("star record not found"))?; #[derive(Serialize)] struct Del<'a> { repo: &'a str, collection: &'a str, rkey: &'a str, } let del = Del { repo: user_did, collection: "sh.tangled.feed.star", rkey: &rkey, }; let _: serde_json::Value = pds_client .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) .await?; Ok(()) }
fn uri_rkey(uri: &str) -> Option<String> { uri.rsplit('/').next().map(|s| s.to_string()) } fn uri_did(uri: &str) -> Option<String> { let parts: Vec<&str> = uri.split('/').collect(); if parts.len() >= 3 { Some(parts[2].to_string()) } else { None } }
// ========== Issues ========== pub async fn list_issues( &self, author_did: &str, repo_at_uri: Option<&str>, bearer: Option<&str>, ) -> Result<Vec<IssueRecord>> { #[derive(Deserialize)] struct Item { uri: String, value: Issue, } #[derive(Deserialize)] struct ListRes { #[serde(default)] records: Vec<Item>, } let params = vec![ ("repo", author_did.to_string()), ("collection", "sh.tangled.repo.issue".to_string()), ("limit", "100".to_string()), ]; let res: ListRes = self .get_json("com.atproto.repo.listRecords", ¶ms, bearer) .await?; let mut out = vec![]; for it in res.records { if let Some(filter_repo) = repo_at_uri { if it.value.repo.as_str() != filter_repo { continue; } } let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); out.push(IssueRecord { author_did: author_did.to_string(), rkey, issue: it.value, }); } Ok(out) }
#[allow(clippy::too_many_arguments)] pub async fn create_issue( &self, author_did: &str, repo_did: &str, repo_rkey: &str, title: &str, body: Option<&str>, pds_base: &str, access_jwt: &str, ) -> Result<String> { #[derive(Serialize)] struct Rec<'a> { repo: &'a str, title: &'a str, #[serde(skip_serializing_if = "Option::is_none")] body: Option<&'a str>, #[serde(rename = "createdAt")] created_at: String, } #[derive(Serialize)] struct Req<'a> { repo: &'a str, collection: &'a str, validate: bool, record: Rec<'a>, } #[derive(Deserialize)] struct Res { uri: String, } let issue_repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); let now = chrono::Utc::now().to_rfc3339(); let rec = Rec { repo: &issue_repo_at, title, body, created_at: now, }; let req = Req { repo: author_did, collection: "sh.tangled.repo.issue", validate: true, record: rec, }; let pds_client = TangledClient::new(pds_base); let res: Res = pds_client .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) .await?; Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue uri")) }
pub async fn comment_issue( &self, author_did: &str, issue_at: &str, body: &str, pds_base: &str, access_jwt: &str, ) -> Result<String> { #[derive(Serialize)] struct Rec<'a> { issue: &'a str, body: &'a str, #[serde(rename = "createdAt")] created_at: String, } #[derive(Serialize)] struct Req<'a> { repo: &'a str, collection: &'a str, validate: bool, record: Rec<'a>, } #[derive(Deserialize)] struct Res { uri: String, } let now = chrono::Utc::now().to_rfc3339(); let rec = Rec { issue: issue_at, body, created_at: now, }; let req = Req { repo: author_did, collection: "sh.tangled.repo.issue.comment", validate: true, record: rec, }; let pds_client = TangledClient::new(pds_base); let res: Res = pds_client .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) .await?; Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue comment uri")) }
pub async fn get_issue_record( &self, author_did: &str, rkey: &str, bearer: Option<&str>, ) -> Result<Issue> { #[derive(Deserialize)] struct GetRes { value: Issue, } let params = [ ("repo", author_did.to_string()), ("collection", "sh.tangled.repo.issue".to_string()), ("rkey", rkey.to_string()), ]; let res: GetRes = self .get_json("com.atproto.repo.getRecord", ¶ms, bearer) .await?; Ok(res.value) }
pub async fn put_issue_record( &self, author_did: &str, rkey: &str, record: &Issue, bearer: Option<&str>, ) -> Result<()> { #[derive(Serialize)] struct PutReq<'a> { repo: &'a str, collection: &'a str, rkey: &'a str, validate: bool, record: &'a Issue, } let req = PutReq { repo: author_did, collection: "sh.tangled.repo.issue", rkey, validate: true, record, }; let _: serde_json::Value = self .post_json("com.atproto.repo.putRecord", &req, bearer) .await?; Ok(()) }
pub async fn set_issue_state( &self, author_did: &str, issue_at: &str, state_nsid: &str, pds_base: &str, access_jwt: &str, ) -> Result<String> { #[derive(Serialize)] struct Rec<'a> { issue: &'a str, state: &'a str, } #[derive(Serialize)] struct Req<'a> { repo: &'a str, collection: &'a str, validate: bool, record: Rec<'a>, } #[derive(Deserialize)] struct Res { uri: String, } let rec = Rec { issue: issue_at, state: state_nsid, }; let req = Req { repo: author_did, collection: "sh.tangled.repo.issue.state", validate: true, record: rec, }; let pds_client = TangledClient::new(pds_base); let res: Res = pds_client .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) .await?; Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri")) }
pub async fn get_pull_record( &self, author_did: &str, rkey: &str, bearer: Option<&str>, ) -> Result<Pull> { #[derive(Deserialize)] struct GetRes { value: Pull, } let params = [ ("repo", author_did.to_string()), ("collection", "sh.tangled.repo.pull".to_string()), ("rkey", rkey.to_string()), ]; let res: GetRes = self .get_json("com.atproto.repo.getRecord", ¶ms, bearer) .await?; Ok(res.value) }
// ========== Pull Requests ========== pub async fn list_pulls( &self, author_did: &str, target_repo_at_uri: Option<&str>, bearer: Option<&str>, ) -> Result<Vec<PullRecord>> { #[derive(Deserialize)] struct Item { uri: String, value: Pull, } #[derive(Deserialize)] struct ListRes { #[serde(default)] records: Vec<Item>, } let params = vec![ ("repo", author_did.to_string()), ("collection", "sh.tangled.repo.pull".to_string()), ("limit", "100".to_string()), ]; let res: ListRes = self .get_json("com.atproto.repo.listRecords", ¶ms, bearer) .await?; let mut out = vec![]; for it in res.records { if let Some(target) = target_repo_at_uri { if it.value.target.repo.as_str() != target { continue; } } let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); out.push(PullRecord { author_did: author_did.to_string(), rkey, pull: it.value, }); } Ok(out) }
#[allow(clippy::too_many_arguments)] pub async fn create_pull( &self, author_did: &str, repo_did: &str, repo_rkey: &str, target_branch: &str, patch: &str, title: &str, body: Option<&str>, pds_base: &str, access_jwt: &str, ) -> Result<String> { #[derive(Serialize)] struct Target<'a> { repo: &'a str, branch: &'a str, } #[derive(Serialize)] struct Rec<'a> { target: Target<'a>, title: &'a str, #[serde(skip_serializing_if = "Option::is_none")] body: Option<&'a str>, patch: &'a str, #[serde(rename = "createdAt")] created_at: String, } #[derive(Serialize)] struct Req<'a> { repo: &'a str, collection: &'a str, validate: bool, record: Rec<'a>, } #[derive(Deserialize)] struct Res { uri: String, } let repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); let now = chrono::Utc::now().to_rfc3339(); let rec = Rec { target: Target { repo: &repo_at, branch: target_branch, }, title, body, patch, created_at: now, }; let req = Req { repo: author_did, collection: "sh.tangled.repo.pull", validate: true, record: rec, }; let pds_client = TangledClient::new(pds_base); let res: Res = pds_client .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) .await?; Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull uri")) }
// ========== Spindle: Secrets Management ========== pub async fn list_repo_secrets( &self, pds_base: &str, access_jwt: &str, repo_at: &str, ) -> Result<Vec<Secret>> { let sa = self.service_auth_token(pds_base, access_jwt).await?; #[derive(Deserialize)] struct Res { secrets: Vec<Secret>, } let params = [("repo", repo_at.to_string())]; let res: Res = self .get_json("sh.tangled.repo.listSecrets", ¶ms, Some(&sa)) .await?; Ok(res.secrets) }
pub async fn add_repo_secret( &self, pds_base: &str, access_jwt: &str, repo_at: &str, key: &str, value: &str, ) -> Result<()> { let sa = self.service_auth_token(pds_base, access_jwt).await?; #[derive(Serialize)] struct Req<'a> { repo: &'a str, key: &'a str, value: &'a str, } let body = Req { repo: repo_at, key, value, }; let _: serde_json::Value = self .post_json("sh.tangled.repo.addSecret", &body, Some(&sa)) .await?; Ok(()) }
pub async fn remove_repo_secret( &self, pds_base: &str, access_jwt: &str, repo_at: &str, key: &str, ) -> Result<()> { let sa = self.service_auth_token(pds_base, access_jwt).await?; #[derive(Serialize)] struct Req<'a> { repo: &'a str, key: &'a str, } let body = Req { repo: repo_at, key }; let _: serde_json::Value = self .post_json("sh.tangled.repo.removeSecret", &body, Some(&sa)) .await?; Ok(()) }
async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> { let host = self .base_url .trim_end_matches('/') .strip_prefix("https://") .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) .ok_or_else(|| anyhow!("invalid base_url"))?; let audience = format!("did:web:{}", host); #[derive(Deserialize)] struct GetSARes { token: String, } let pds = TangledClient::new(pds_base); let params = [ ("aud", audience), ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), ]; let sa: GetSARes = pds .get_json( "com.atproto.server.getServiceAuth", ¶ms, Some(access_jwt), ) .await?; Ok(sa.token) }
pub async fn comment_pull( &self, author_did: &str, pull_at: &str, body: &str, pds_base: &str, access_jwt: &str, ) -> Result<String> { #[derive(Serialize)] struct Rec<'a> { pull: &'a str, body: &'a str, #[serde(rename = "createdAt")] created_at: String, } #[derive(Serialize)] struct Req<'a> { repo: &'a str, collection: &'a str, validate: bool, record: Rec<'a>, } #[derive(Deserialize)] struct Res { uri: String, } let now = chrono::Utc::now().to_rfc3339(); let rec = Rec { pull: pull_at, body, created_at: now, }; let req = Req { repo: author_did, collection: "sh.tangled.repo.pull.comment", validate: true, record: rec, }; let pds_client = TangledClient::new(pds_base); let res: Res = pds_client .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) .await?; Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri")) }}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]pub struct Repository { pub did: Option<String>, pub rkey: Option<String>, pub name: String, pub knot: Option<String>, pub description: Option<String>, #[serde(default)] pub private: bool,}
// Issue record value#[derive(Debug, Clone, Serialize, Deserialize)]pub struct Issue { pub repo: String, pub title: String, #[serde(default)] pub body: String, #[serde(rename = "createdAt")] pub created_at: String,}
#[derive(Debug, Clone)]pub struct IssueRecord { pub author_did: String, pub rkey: String, pub issue: Issue,}
// Pull record value (subset)#[derive(Debug, Clone, Serialize, Deserialize)]pub struct PullTarget { pub repo: String, pub branch: String,}
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct Pull { pub target: PullTarget, pub title: String, #[serde(default)] pub body: String, pub patch: String, #[serde(rename = "createdAt")] pub created_at: String,}
#[derive(Debug, Clone)]pub struct PullRecord { pub author_did: String, pub rkey: String, pub pull: Pull,}
#[derive(Debug, Clone)]pub struct RepoRecord { pub did: String, pub name: String, pub rkey: String, pub knot: String, pub description: Option<String>,}
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct DefaultBranch { pub name: String, pub hash: String, #[serde(skip_serializing_if = "Option::is_none")] pub short_hash: Option<String>, pub when: String, #[serde(skip_serializing_if = "Option::is_none")] pub message: Option<String>,}
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct Language { pub name: String, pub size: u64, pub percentage: u64,}
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct Languages { pub languages: Vec<Language>, #[serde(skip_serializing_if = "Option::is_none")] pub total_size: Option<u64>, #[serde(skip_serializing_if = "Option::is_none")] pub total_files: Option<u64>,}
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct StarRecord { pub subject: String, #[serde(rename = "createdAt")] pub created_at: String,}
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct Secret { pub repo: String, pub key: String, #[serde(rename = "createdAt")] pub created_at: String, #[serde(rename = "createdBy")] pub created_by: String,}
#[derive(Debug, Clone)]pub struct CreateRepoOptions<'a> { pub did: &'a str, pub name: &'a str, pub knot: &'a str, pub description: Option<&'a str>, pub default_branch: Option<&'a str>, pub source: Option<&'a str>, pub pds_base: &'a str, pub access_jwt: &'a str,}