From c1ea5141a6f7d13eb56cd9f270886d28ef54410d Mon Sep 17 00:00:00 2001 From: Vitor Py Braga <12871+vitorpy@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:21:02 +0200 Subject: [PATCH] CLI: issues, PRs, spindle secrets; polish --- crates/tangled-api/src/client.rs | 546 +++++++++++++++++++++ crates/tangled-api/src/lib.rs | 4 + crates/tangled-cli/src/cli.rs | 43 ++ crates/tangled-cli/src/commands/issue.rs | 229 ++++++++- crates/tangled-cli/src/commands/pr.rs | 203 +++++++- crates/tangled-cli/src/commands/spindle.rs | 98 +++- 6 files changed, 1081 insertions(+), 42 deletions(-) diff --git a/crates/tangled-api/src/client.rs b/crates/tangled-api/src/client.rs index a67a276..369e6fb 100644 --- a/crates/tangled-api/src/client.rs +++ b/crates/tangled-api/src/client.rs @@ -629,6 +629,499 @@ impl TangledClient { None } } + + // ========== Issues ========== + pub async fn list_issues( + &self, + author_did: &str, + repo_at_uri: Option<&str>, + bearer: Option<&str>, + ) -> Result> { + #[derive(Deserialize)] + struct Item { + uri: String, + value: Issue, + } + #[derive(Deserialize)] + struct ListRes { + #[serde(default)] + records: Vec, + } + 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 { + #[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 { + #[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 { + #[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 { + #[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 { + #[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> { + #[derive(Deserialize)] + struct Item { + uri: String, + value: Pull, + } + #[derive(Deserialize)] + struct ListRes { + #[serde(default)] + records: Vec, + } + 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 { + #[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> { + let sa = self.service_auth_token(pds_base, access_jwt).await?; + #[derive(Deserialize)] + struct Res { + secrets: Vec, + } + 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 { + 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 { + #[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)] @@ -642,6 +1135,49 @@ pub struct Repository { 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, @@ -685,6 +1221,16 @@ pub struct StarRecord { 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, diff --git a/crates/tangled-api/src/lib.rs b/crates/tangled-api/src/lib.rs index ab72761..661c21a 100644 --- a/crates/tangled-api/src/lib.rs +++ b/crates/tangled-api/src/lib.rs @@ -1,3 +1,7 @@ pub mod client; pub use client::TangledClient; +pub use client::{ + CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, Pull, PullRecord, + RepoRecord, Repository, Secret, +}; diff --git a/crates/tangled-cli/src/cli.rs b/crates/tangled-cli/src/cli.rs index eec752b..0ea9f9e 100644 --- a/crates/tangled-cli/src/cli.rs +++ b/crates/tangled-cli/src/cli.rs @@ -354,6 +354,9 @@ pub enum SpindleCommand { Config(SpindleConfigArgs), Run(SpindleRunArgs), Logs(SpindleLogsArgs), + /// Secrets management + #[command(subcommand)] + Secret(SpindleSecretCommand), } #[derive(Args, Debug, Clone)] @@ -392,3 +395,43 @@ pub struct SpindleLogsArgs { #[arg(long)] pub lines: Option, } + +#[derive(Subcommand, Debug, Clone)] +pub enum SpindleSecretCommand { + /// List secrets for a repo + List(SpindleSecretListArgs), + /// Add or update a secret + Add(SpindleSecretAddArgs), + /// Remove a secret + Remove(SpindleSecretRemoveArgs), +} + +#[derive(Args, Debug, Clone)] +pub struct SpindleSecretListArgs { + /// Repo: / + #[arg(long)] + pub repo: String, +} + +#[derive(Args, Debug, Clone)] +pub struct SpindleSecretAddArgs { + /// Repo: / + #[arg(long)] + pub repo: String, + /// Secret key + #[arg(long)] + pub key: String, + /// Secret value + #[arg(long)] + pub value: String, +} + +#[derive(Args, Debug, Clone)] +pub struct SpindleSecretRemoveArgs { + /// Repo: / + #[arg(long)] + pub repo: String, + /// Secret key + #[arg(long)] + pub key: String, +} diff --git a/crates/tangled-cli/src/commands/issue.rs b/crates/tangled-cli/src/commands/issue.rs index 3a2e92d..447c43a 100644 --- a/crates/tangled-cli/src/commands/issue.rs +++ b/crates/tangled-cli/src/commands/issue.rs @@ -2,7 +2,9 @@ use crate::cli::{ Cli, IssueCommand, IssueCommentArgs, IssueCreateArgs, IssueEditArgs, IssueListArgs, IssueShowArgs, }; -use anyhow::Result; +use anyhow::{anyhow, Result}; +use tangled_api::Issue; +use tangled_config::session::SessionManager; pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> { match cmd { @@ -15,41 +17,226 @@ pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> { } async fn list(args: IssueListArgs) -> Result<()> { - println!( - "Issue list (stub) repo={:?} state={:?} author={:?} label={:?} assigned={:?}", - args.repo, args.state, args.author, args.label, args.assigned - ); + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + let client = tangled_api::TangledClient::new(&pds); + + let repo_filter_at = if let Some(repo) = &args.repo { + let (owner, name) = parse_repo_ref(repo, &session.handle); + let info = client + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) + .await?; + Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey)) + } else { + None + }; + + let items = client + .list_issues( + &session.did, + repo_filter_at.as_deref(), + Some(session.access_jwt.as_str()), + ) + .await?; + if items.is_empty() { + println!("No issues found (showing only issues you created)"); + } else { + println!("RKEY\tTITLE\tREPO"); + for it in items { + println!("{}\t{}\t{}", it.rkey, it.issue.title, it.issue.repo); + } + } Ok(()) } async fn create(args: IssueCreateArgs) -> Result<()> { - println!( - "Issue create (stub) repo={:?} title={:?} body={:?} labels={:?} assign={:?}", - args.repo, args.title, args.body, args.label, args.assign - ); + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + let client = tangled_api::TangledClient::new(&pds); + + let repo = args + .repo + .as_ref() + .ok_or_else(|| anyhow!("--repo is required for issue create"))?; + let (owner, name) = parse_repo_ref(repo, &session.handle); + let info = client + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) + .await?; + let title = args + .title + .as_deref() + .ok_or_else(|| anyhow!("--title is required for issue create"))?; + let rkey = client + .create_issue( + &session.did, + &info.did, + &info.rkey, + title, + args.body.as_deref(), + &pds, + &session.access_jwt, + ) + .await?; + println!("Created issue rkey={} in {}/{}", rkey, owner, name); Ok(()) } async fn show(args: IssueShowArgs) -> Result<()> { - println!( - "Issue show (stub) id={} comments={} json={}", - args.id, args.comments, args.json - ); + // For now, show only accepts at-uri or did:rkey or rkey (for your DID) + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let id = args.id; + let (did, rkey) = parse_record_id(&id, &session.did)?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + let client = tangled_api::TangledClient::new(&pds); + // Fetch all issues by this DID and find rkey + let items = client + .list_issues(&did, None, Some(session.access_jwt.as_str())) + .await?; + if let Some(it) = items.into_iter().find(|i| i.rkey == rkey) { + println!("TITLE: {}", it.issue.title); + if !it.issue.body.is_empty() { + println!("BODY:\n{}", it.issue.body); + } + println!("REPO: {}", it.issue.repo); + println!("AUTHOR: {}", it.author_did); + println!("RKEY: {}", rkey); + } else { + println!("Issue not found for did={} rkey={}", did, rkey); + } Ok(()) } async fn edit(args: IssueEditArgs) -> Result<()> { - println!( - "Issue edit (stub) id={} title={:?} body={:?} state={:?}", - args.id, args.title, args.body, args.state - ); + // Simple edit: fetch existing record and putRecord with new title/body + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let (did, rkey) = parse_record_id(&args.id, &session.did)?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + // Get existing + let client = tangled_api::TangledClient::new(&pds); + let mut rec: Issue = client + .get_issue_record(&did, &rkey, Some(session.access_jwt.as_str())) + .await?; + if let Some(t) = args.title.as_deref() { + rec.title = t.to_string(); + } + if let Some(b) = args.body.as_deref() { + rec.body = b.to_string(); + } + // Put record back + client + .put_issue_record(&did, &rkey, &rec, Some(session.access_jwt.as_str())) + .await?; + + // Optional state change + if let Some(state) = args.state.as_deref() { + let state_nsid = match state { + "open" => "sh.tangled.repo.issue.state.open", + "closed" => "sh.tangled.repo.issue.state.closed", + other => { + return Err(anyhow!(format!( + "unknown state '{}', expected 'open' or 'closed'", + other + ))) + } + }; + let issue_at = rec.repo.clone(); + client + .set_issue_state( + &session.did, + &issue_at, + state_nsid, + &pds, + &session.access_jwt, + ) + .await?; + } + println!("Updated issue {}:{}", did, rkey); Ok(()) } async fn comment(args: IssueCommentArgs) -> Result<()> { - println!( - "Issue comment (stub) id={} close={} body={:?}", - args.id, args.close, args.body - ); + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let (did, rkey) = parse_record_id(&args.id, &session.did)?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + // Resolve issue AT-URI + let client = tangled_api::TangledClient::new(&pds); + let issue_at = client + .get_issue_record(&did, &rkey, Some(session.access_jwt.as_str())) + .await? + .repo; + if let Some(body) = args.body.as_deref() { + client + .comment_issue(&session.did, &issue_at, body, &pds, &session.access_jwt) + .await?; + println!("Comment posted"); + } + if args.close { + client + .set_issue_state( + &session.did, + &issue_at, + "sh.tangled.repo.issue.state.closed", + &pds, + &session.access_jwt, + ) + .await?; + println!("Issue closed"); + } Ok(()) } + +fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) { + if let Some((owner, name)) = spec.split_once('/') { + (owner, name) + } else { + (default_owner, spec) + } +} + +fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> { + if let Some(rest) = id.strip_prefix("at://") { + let parts: Vec<&str> = rest.split('/').collect(); + if parts.len() >= 4 { + return Ok((parts[0].to_string(), parts[3].to_string())); + } + } + if let Some((did, rkey)) = id.split_once(':') { + return Ok((did.to_string(), rkey.to_string())); + } + Ok((default_did.to_string(), id.to_string())) +} diff --git a/crates/tangled-cli/src/commands/pr.rs b/crates/tangled-cli/src/commands/pr.rs index 096ab49..b42b767 100644 --- a/crates/tangled-cli/src/commands/pr.rs +++ b/crates/tangled-cli/src/commands/pr.rs @@ -1,5 +1,8 @@ use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrMergeArgs, PrReviewArgs, PrShowArgs}; -use anyhow::Result; +use anyhow::{anyhow, Result}; +use std::path::Path; +use std::process::Command; +use tangled_config::session::SessionManager; pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> { match cmd { @@ -12,41 +15,201 @@ pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> { } async fn list(args: PrListArgs) -> Result<()> { - println!( - "PR list (stub) repo={:?} state={:?} author={:?} reviewer={:?}", - args.repo, args.state, args.author, args.reviewer - ); + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + let client = tangled_api::TangledClient::new(&pds); + let target_repo_at = if let Some(repo) = &args.repo { + let (owner, name) = parse_repo_ref(repo, &session.handle); + let info = client + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) + .await?; + Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey)) + } else { + None + }; + let pulls = client + .list_pulls( + &session.did, + target_repo_at.as_deref(), + Some(session.access_jwt.as_str()), + ) + .await?; + if pulls.is_empty() { + println!("No pull requests found (showing only those you created)"); + } else { + println!("RKEY\tTITLE\tTARGET"); + for pr in pulls { + println!("{}\t{}\t{}", pr.rkey, pr.pull.title, pr.pull.target.repo); + } + } Ok(()) } async fn create(args: PrCreateArgs) -> Result<()> { + // Must be run inside the repo checkout; we will use git format-patch to build the patch + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + let client = tangled_api::TangledClient::new(&pds); + + let repo = args + .repo + .as_ref() + .ok_or_else(|| anyhow!("--repo is required for pr create"))?; + let (owner, name) = parse_repo_ref(repo, ""); + let info = client + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) + .await?; + + let base = args + .base + .as_deref() + .ok_or_else(|| anyhow!("--base is required (target branch)"))?; + let head = args + .head + .as_deref() + .ok_or_else(|| anyhow!("--head is required (source range/branch)"))?; + + // Generate format-patch using external git for fidelity + let output = Command::new("git") + .arg("format-patch") + .arg("--stdout") + .arg(format!("{}..{}", base, head)) + .current_dir(Path::new(".")) + .output()?; + if !output.status.success() { + return Err(anyhow!("failed to run git format-patch")); + } + let patch = String::from_utf8_lossy(&output.stdout).to_string(); + if patch.trim().is_empty() { + return Err(anyhow!("no changes between base and head")); + } + + let title_buf; + let title = if let Some(t) = args.title.as_deref() { + t + } else { + title_buf = format!("{} -> {}", head, base); + &title_buf + }; + let rkey = client + .create_pull( + &session.did, + &info.did, + &info.rkey, + base, + &patch, + title, + args.body.as_deref(), + &pds, + &session.access_jwt, + ) + .await?; println!( - "PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}", - args.repo, args.base, args.head, args.title, args.draft + "Created PR rkey={} targeting {} branch {}", + rkey, info.did, base ); Ok(()) } async fn show(args: PrShowArgs) -> Result<()> { - println!( - "PR show (stub) id={} diff={} comments={} checks={}", - args.id, args.diff, args.comments, args.checks - ); + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let (did, rkey) = parse_record_id(&args.id, &session.did)?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + let client = tangled_api::TangledClient::new(&pds); + let pr = client + .get_pull_record(&did, &rkey, Some(session.access_jwt.as_str())) + .await?; + println!("TITLE: {}", pr.title); + if !pr.body.is_empty() { + println!("BODY:\n{}", pr.body); + } + println!("TARGET: {} @ {}", pr.target.repo, pr.target.branch); + if args.diff { + println!("PATCH:\n{}", pr.patch); + } Ok(()) } async fn review(args: PrReviewArgs) -> Result<()> { - println!( - "PR review (stub) id={} approve={} request_changes={} comment={:?}", - args.id, args.approve, args.request_changes, args.comment - ); + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let (did, rkey) = parse_record_id(&args.id, &session.did)?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + let pr_at = format!("at://{}/sh.tangled.repo.pull/{}", did, rkey); + let note = if let Some(c) = args.comment.as_deref() { + c + } else if args.approve { + "LGTM" + } else if args.request_changes { + "Requesting changes" + } else { + "" + }; + if note.is_empty() { + return Err(anyhow!("provide --comment or --approve/--request-changes")); + } + let client = tangled_api::TangledClient::new(&pds); + client + .comment_pull(&session.did, &pr_at, note, &pds, &session.access_jwt) + .await?; + println!("Review comment posted"); Ok(()) } -async fn merge(args: PrMergeArgs) -> Result<()> { - println!( - "PR merge (stub) id={} squash={} rebase={} no_ff={}", - args.id, args.squash, args.rebase, args.no_ff - ); +async fn merge(_args: PrMergeArgs) -> Result<()> { + // Placeholder: merging requires server-side merge call with the patch and target branch. + println!("Merge via CLI is not implemented yet. Use the web UI for now."); Ok(()) } + +fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) { + if let Some((owner, name)) = spec.split_once('/') { + if !owner.is_empty() { + (owner, name) + } else { + (default_owner, name) + } + } else { + (default_owner, spec) + } +} + +fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> { + if let Some(rest) = id.strip_prefix("at://") { + let parts: Vec<&str> = rest.split('/').collect(); + if parts.len() >= 4 { + return Ok((parts[0].to_string(), parts[3].to_string())); + } + } + if let Some((did, rkey)) = id.split_once(':') { + return Ok((did.to_string(), rkey.to_string())); + } + Ok((default_did.to_string(), id.to_string())) +} diff --git a/crates/tangled-cli/src/commands/spindle.rs b/crates/tangled-cli/src/commands/spindle.rs index e1491b5..4ddaa1b 100644 --- a/crates/tangled-cli/src/commands/spindle.rs +++ b/crates/tangled-cli/src/commands/spindle.rs @@ -1,7 +1,9 @@ use crate::cli::{ Cli, SpindleCommand, SpindleConfigArgs, SpindleListArgs, SpindleLogsArgs, SpindleRunArgs, + SpindleSecretAddArgs, SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs, }; -use anyhow::Result; +use anyhow::{anyhow, Result}; +use tangled_config::session::SessionManager; pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> { match cmd { @@ -9,6 +11,7 @@ pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> { SpindleCommand::Config(args) => config(args).await, SpindleCommand::Run(args) => run_pipeline(args).await, SpindleCommand::Logs(args) => logs(args).await, + SpindleCommand::Secret(cmd) => secret(cmd).await, } } @@ -40,3 +43,96 @@ async fn logs(args: SpindleLogsArgs) -> Result<()> { ); Ok(()) } + +async fn secret(cmd: SpindleSecretCommand) -> Result<()> { + match cmd { + SpindleSecretCommand::List(args) => secret_list(args).await, + SpindleSecretCommand::Add(args) => secret_add(args).await, + SpindleSecretCommand::Remove(args) => secret_remove(args).await, + } +} + +async fn secret_list(args: SpindleSecretListArgs) -> Result<()> { + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + let pds_client = tangled_api::TangledClient::new(&pds); + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); + let info = pds_client + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) + .await?; + let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); + let api = tangled_api::TangledClient::default(); // base tngl.sh + let secrets = api + .list_repo_secrets(&pds, &session.access_jwt, &repo_at) + .await?; + if secrets.is_empty() { + println!("No secrets configured for {}", args.repo); + } else { + println!("KEY\tCREATED AT\tCREATED BY"); + for s in secrets { + println!("{}\t{}\t{}", s.key, s.created_at, s.created_by); + } + } + Ok(()) +} + +async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> { + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + let pds_client = tangled_api::TangledClient::new(&pds); + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); + let info = pds_client + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) + .await?; + let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); + let api = tangled_api::TangledClient::default(); + api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value) + .await?; + println!("Added secret '{}' to {}", args.key, args.repo); + Ok(()) +} + +async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> { + let mgr = SessionManager::default(); + let session = mgr + .load()? + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; + let pds = session + .pds + .clone() + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) + .unwrap_or_else(|| "https://bsky.social".into()); + let pds_client = tangled_api::TangledClient::new(&pds); + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); + let info = pds_client + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) + .await?; + let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); + let api = tangled_api::TangledClient::default(); + api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key) + .await?; + println!("Removed secret '{}' from {}", args.key, args.repo); + Ok(()) +} + +fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) { + if let Some((owner, name)) = spec.split_once('/') { + (owner, name) + } else { + (default_owner, spec) + } +} -- 2.51.2