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

Changed files
+120 -83
crates
tangled-api
src
tangled-cli
src
commands
+54 -1
crates/tangled-api/src/client.rs
···
Ok(res.json::<TRes>().await?)
}
-
async fn get_json<TRes: DeserializeOwned>(
+
pub async fn get_json<TRes: DeserializeOwned>(
&self,
method: &str,
params: &[(&str, String)],
···
.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"))
+
}
+
+
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(())
-40
crates/tangled-cli/src/cli.rs
···
#[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<String>,
-
#[arg(long)]
-
pub name: Option<String>,
-
#[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)]
+1 -39
crates/tangled-cli/src/commands/knot.rs
···
-
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};
···
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<()> {
+65 -3
crates/tangled-cli/src/commands/pr.rs
···
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", &params, 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(())
}