CLI: issues, PRs, spindle secrets; polish

Changed files
+1081 -42
crates
tangled-api
tangled-cli
+546
crates/tangled-api/src/client.rs
···
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
···
pub private: bool,
}
#[derive(Debug, Clone)]
pub struct RepoRecord {
pub did: String,
···
pub subject: String,
#[serde(rename = "createdAt")]
pub created_at: String,
}
#[derive(Debug, Clone)]
···
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", &params, 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", &params, 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", &params, 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", &params, 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", &params, 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",
+
&params,
+
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 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 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)]
+4
crates/tangled-api/src/lib.rs
···
pub mod client;
pub use client::TangledClient;
···
pub mod client;
pub use client::TangledClient;
+
pub use client::{
+
CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, Pull, PullRecord,
+
RepoRecord, Repository, Secret,
+
};
+43
crates/tangled-cli/src/cli.rs
···
Config(SpindleConfigArgs),
Run(SpindleRunArgs),
Logs(SpindleLogsArgs),
}
#[derive(Args, Debug, Clone)]
···
#[arg(long)]
pub lines: Option<usize>,
}
···
Config(SpindleConfigArgs),
Run(SpindleRunArgs),
Logs(SpindleLogsArgs),
+
/// Secrets management
+
#[command(subcommand)]
+
Secret(SpindleSecretCommand),
}
#[derive(Args, Debug, Clone)]
···
#[arg(long)]
pub lines: Option<usize>,
}
+
+
#[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: <owner>/<name>
+
#[arg(long)]
+
pub repo: String,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct SpindleSecretAddArgs {
+
/// Repo: <owner>/<name>
+
#[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: <owner>/<name>
+
#[arg(long)]
+
pub repo: String,
+
/// Secret key
+
#[arg(long)]
+
pub key: String,
+
}
+208 -21
crates/tangled-cli/src/commands/issue.rs
···
Cli, IssueCommand, IssueCommentArgs, IssueCreateArgs, IssueEditArgs, IssueListArgs,
IssueShowArgs,
};
-
use anyhow::Result;
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
match cmd {
···
}
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
-
);
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
-
);
Ok(())
}
async fn show(args: IssueShowArgs) -> Result<()> {
-
println!(
-
"Issue show (stub) id={} comments={} json={}",
-
args.id, args.comments, args.json
-
);
Ok(())
}
async fn edit(args: IssueEditArgs) -> Result<()> {
-
println!(
-
"Issue edit (stub) id={} title={:?} body={:?} state={:?}",
-
args.id, args.title, args.body, args.state
-
);
Ok(())
}
async fn comment(args: IssueCommentArgs) -> Result<()> {
-
println!(
-
"Issue comment (stub) id={} close={} body={:?}",
-
args.id, args.close, args.body
-
);
Ok(())
}
···
Cli, IssueCommand, IssueCommentArgs, IssueCreateArgs, IssueEditArgs, IssueListArgs,
IssueShowArgs,
};
+
use anyhow::{anyhow, Result};
+
use tangled_api::Issue;
+
use tangled_config::session::SessionManager;
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
match cmd {
···
}
async fn list(args: IssueListArgs) -> 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 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<()> {
+
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<()> {
+
// 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<()> {
+
// 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<()> {
+
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()))
+
}
+183 -20
crates/tangled-cli/src/commands/pr.rs
···
use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrMergeArgs, PrReviewArgs, PrShowArgs};
-
use anyhow::Result;
pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> {
match cmd {
···
}
async fn list(args: PrListArgs) -> Result<()> {
-
println!(
-
"PR list (stub) repo={:?} state={:?} author={:?} reviewer={:?}",
-
args.repo, args.state, args.author, args.reviewer
-
);
Ok(())
}
async fn create(args: PrCreateArgs) -> Result<()> {
println!(
-
"PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}",
-
args.repo, args.base, args.head, args.title, args.draft
);
Ok(())
}
async fn show(args: PrShowArgs) -> Result<()> {
-
println!(
-
"PR show (stub) id={} diff={} comments={} checks={}",
-
args.id, args.diff, args.comments, args.checks
-
);
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
-
);
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
-
);
Ok(())
}
···
use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrMergeArgs, PrReviewArgs, PrShowArgs};
+
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 {
···
}
async fn list(args: PrListArgs) -> 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 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!(
+
"Created PR rkey={} targeting {} branch {}",
+
rkey, info.did, base
);
Ok(())
}
async fn show(args: PrShowArgs) -> 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());
+
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<()> {
+
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<()> {
+
// 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()))
+
}
+97 -1
crates/tangled-cli/src/commands/spindle.rs
···
use crate::cli::{
Cli, SpindleCommand, SpindleConfigArgs, SpindleListArgs, SpindleLogsArgs, SpindleRunArgs,
};
-
use anyhow::Result;
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
match cmd {
···
SpindleCommand::Config(args) => config(args).await,
SpindleCommand::Run(args) => run_pipeline(args).await,
SpindleCommand::Logs(args) => logs(args).await,
}
}
···
);
Ok(())
}
···
use crate::cli::{
Cli, SpindleCommand, SpindleConfigArgs, SpindleListArgs, SpindleLogsArgs, SpindleRunArgs,
+
SpindleSecretAddArgs, SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs,
};
+
use anyhow::{anyhow, Result};
+
use tangled_config::session::SessionManager;
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
match cmd {
···
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,
}
}
···
);
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)
+
}
+
}