From 6ee4129ccbd1fa9955cbee62e7299d4e8d41f541 Mon Sep 17 00:00:00 2001 From: Vitor Py Date: Tue, 14 Oct 2025 15:02:03 +0200 Subject: [PATCH] Implement PR merge, remove unused knot commands - Add merge_pull() to API client using sh.tangled.repo.merge - Implement 'pr merge' CLI command with ServiceAuth flow - Remove stub knot commands (list, add, verify, set-default, remove) - Keep only 'knot migrate' which is fully implemented --- crates/tangled-api/src/client.rs | 55 +++++++++++++++++++- crates/tangled-cli/src/cli.rs | 40 --------------- crates/tangled-cli/src/commands/knot.rs | 40 +-------------- crates/tangled-cli/src/commands/pr.rs | 68 +++++++++++++++++++++++-- 4 files changed, 120 insertions(+), 83 deletions(-) diff --git a/crates/tangled-api/src/client.rs b/crates/tangled-api/src/client.rs index 369e6fb..e96153a 100644 --- a/crates/tangled-api/src/client.rs +++ b/crates/tangled-api/src/client.rs @@ -49,7 +49,7 @@ impl TangledClient { Ok(res.json::().await?) } - async fn get_json( + pub async fn get_json( &self, method: &str, params: &[(&str, String)], @@ -1122,6 +1122,59 @@ impl TangledClient { .await?; Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri")) } + + pub async fn merge_pull( + &self, + pull_did: &str, + pull_rkey: &str, + repo_did: &str, + repo_name: &str, + pds_base: &str, + access_jwt: &str, + ) -> Result<()> { + // Fetch the pull request to get patch and target branch + let pds_client = TangledClient::new(pds_base); + let pull = pds_client + .get_pull_record(pull_did, pull_rkey, Some(access_jwt)) + .await?; + + // Get service auth token for the knot + let sa = self.service_auth_token(pds_base, access_jwt).await?; + + #[derive(Serialize)] + struct MergeReq<'a> { + did: &'a str, + name: &'a str, + patch: &'a str, + branch: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "commitMessage")] + commit_message: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "commitBody")] + commit_body: Option<&'a str>, + } + + let commit_body = if pull.body.is_empty() { + None + } else { + Some(pull.body.as_str()) + }; + + let req = MergeReq { + did: repo_did, + name: repo_name, + patch: &pull.patch, + branch: &pull.target.branch, + commit_message: Some(&pull.title), + commit_body, + }; + + let _: serde_json::Value = self + .post_json("sh.tangled.repo.merge", &req, Some(&sa)) + .await?; + Ok(()) + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/tangled-cli/src/cli.rs b/crates/tangled-cli/src/cli.rs index 0ea9f9e..2c71f9b 100644 --- a/crates/tangled-cli/src/cli.rs +++ b/crates/tangled-cli/src/cli.rs @@ -284,54 +284,14 @@ pub struct PrReviewArgs { #[derive(Args, Debug, Clone)] pub struct PrMergeArgs { pub id: String, - #[arg(long, default_value_t = false)] - pub squash: bool, - #[arg(long, default_value_t = false)] - pub rebase: bool, - #[arg(long, default_value_t = false)] - pub no_ff: bool, } #[derive(Subcommand, Debug, Clone)] pub enum KnotCommand { - List(KnotListArgs), - Add(KnotAddArgs), - Verify(KnotVerifyArgs), - SetDefault(KnotRefArgs), - Remove(KnotRefArgs), /// Migrate a repository to another knot Migrate(KnotMigrateArgs), } -#[derive(Args, Debug, Clone)] -pub struct KnotListArgs { - #[arg(long, default_value_t = false)] - pub public: bool, - #[arg(long, default_value_t = false)] - pub owned: bool, -} - -#[derive(Args, Debug, Clone)] -pub struct KnotAddArgs { - pub url: String, - #[arg(long)] - pub did: Option, - #[arg(long)] - pub name: Option, - #[arg(long, default_value_t = false)] - pub verify: bool, -} - -#[derive(Args, Debug, Clone)] -pub struct KnotVerifyArgs { - pub url: String, -} - -#[derive(Args, Debug, Clone)] -pub struct KnotRefArgs { - pub url: String, -} - #[derive(Args, Debug, Clone)] pub struct KnotMigrateArgs { /// Repo to migrate: / (owner defaults to your handle) diff --git a/crates/tangled-cli/src/commands/knot.rs b/crates/tangled-cli/src/commands/knot.rs index 83bf802..cac9094 100644 --- a/crates/tangled-cli/src/commands/knot.rs +++ b/crates/tangled-cli/src/commands/knot.rs @@ -1,6 +1,4 @@ -use crate::cli::{ - Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotMigrateArgs, KnotRefArgs, KnotVerifyArgs, -}; +use crate::cli::{Cli, KnotCommand, KnotMigrateArgs}; use anyhow::anyhow; use anyhow::Result; use git2::{Direction, Repository as GitRepository, StatusOptions}; @@ -9,46 +7,10 @@ use tangled_config::session::SessionManager; pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> { match cmd { - KnotCommand::List(args) => list(args).await, - KnotCommand::Add(args) => add(args).await, - KnotCommand::Verify(args) => verify(args).await, - KnotCommand::SetDefault(args) => set_default(args).await, - KnotCommand::Remove(args) => remove(args).await, KnotCommand::Migrate(args) => migrate(args).await, } } -async fn list(args: KnotListArgs) -> Result<()> { - println!( - "Knot list (stub) public={} owned={}", - args.public, args.owned - ); - Ok(()) -} - -async fn add(args: KnotAddArgs) -> Result<()> { - println!( - "Knot add (stub) url={} did={:?} name={:?} verify={}", - args.url, args.did, args.name, args.verify - ); - Ok(()) -} - -async fn verify(args: KnotVerifyArgs) -> Result<()> { - println!("Knot verify (stub) url={}", args.url); - Ok(()) -} - -async fn set_default(args: KnotRefArgs) -> Result<()> { - println!("Knot set-default (stub) url={}", args.url); - Ok(()) -} - -async fn remove(args: KnotRefArgs) -> Result<()> { - println!("Knot remove (stub) url={}", args.url); - Ok(()) -} - async fn migrate(args: KnotMigrateArgs) -> Result<()> { let mgr = SessionManager::default(); let session = mgr diff --git a/crates/tangled-cli/src/commands/pr.rs b/crates/tangled-cli/src/commands/pr.rs index b42b767..a1251e3 100644 --- a/crates/tangled-cli/src/commands/pr.rs +++ b/crates/tangled-cli/src/commands/pr.rs @@ -183,9 +183,71 @@ async fn review(args: PrReviewArgs) -> Result<()> { Ok(()) } -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."); +async fn merge(args: PrMergeArgs) -> Result<()> { + 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 the PR to find the target repo + let pds_client = tangled_api::TangledClient::new(&pds); + let pull = pds_client + .get_pull_record(&did, &rkey, Some(session.access_jwt.as_str())) + .await?; + + // Parse the target repo AT-URI to get did and name + let target_repo = &pull.target.repo; + // Format: at://did:plc:.../sh.tangled.repo/rkey + let parts: Vec<&str> = target_repo.strip_prefix("at://").unwrap_or(target_repo).split('/').collect(); + if parts.len() < 2 { + return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); + } + let repo_did = parts[0]; + + // Get repo info to find the name + // Parse rkey from target repo AT-URI + let repo_rkey = if parts.len() >= 4 { + parts[3] + } else { + return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); + }; + + #[derive(serde::Deserialize)] + struct Rec { + name: String, + } + #[derive(serde::Deserialize)] + struct GetRes { + value: Rec, + } + let params = [ + ("repo", repo_did.to_string()), + ("collection", "sh.tangled.repo".to_string()), + ("rkey", repo_rkey.to_string()), + ]; + let repo_rec: GetRes = pds_client + .get_json("com.atproto.repo.getRecord", ¶ms, Some(session.access_jwt.as_str())) + .await?; + + // Call merge on the default Tangled API base (tngl.sh) + let api = tangled_api::TangledClient::default(); + api.merge_pull( + &did, + &rkey, + repo_did, + &repo_rec.value.name, + &pds, + &session.access_jwt, + ) + .await?; + + println!("Merged PR {}:{}", did, rkey); Ok(()) } -- 2.51.2