diff --git a/crates/tangled-cli/src/cli.rs b/crates/tangled-cli/src/cli.rs index 37d13e7..bdf5133 100644 --- a/crates/tangled-cli/src/cli.rs +++ b/crates/tangled-cli/src/cli.rs @@ -465,4 +465,13 @@ mod tests { let cli = Cli::try_parse_from(["tangled", "pr", "create"]).unwrap(); assert!(matches!(cli.command, Command::Pr(PrCommand::Create(_)))); } + + #[test] + fn parses_convenience_issue_create_without_repo_or_title() { + let cli = Cli::try_parse_from(["tangled", "issue", "create"]).unwrap(); + assert!(matches!( + cli.command, + Command::Issue(IssueCommand::Create(_)) + )); + } } diff --git a/crates/tangled-cli/src/commands/issue.rs b/crates/tangled-cli/src/commands/issue.rs index 46cc3b8..403178c 100644 --- a/crates/tangled-cli/src/commands/issue.rs +++ b/crates/tangled-cli/src/commands/issue.rs @@ -3,19 +3,37 @@ use crate::cli::{ IssueShowArgs, }; use anyhow::{anyhow, Result}; +use dialoguer::Input; +use std::io::IsTerminal; use tangled_api::Issue; pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> { match cmd { - IssueCommand::List(args) => list(args).await, - IssueCommand::Create(args) => create(args).await, + IssueCommand::List(args) => list(args, IssueMode::Convenience).await, + IssueCommand::Create(args) => create(args, IssueMode::Convenience).await, IssueCommand::Show(args) => show(args).await, IssueCommand::Edit(args) => edit(args).await, IssueCommand::Comment(args) => comment(args).await, } } -async fn list(args: IssueListArgs) -> Result<()> { +pub async fn run_api(_cli: &Cli, cmd: IssueCommand) -> Result<()> { + match cmd { + IssueCommand::List(args) => list(args, IssueMode::Api).await, + IssueCommand::Create(args) => create(args, IssueMode::Api).await, + IssueCommand::Show(args) => show(args).await, + IssueCommand::Edit(args) => edit(args).await, + IssueCommand::Comment(args) => comment(args).await, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IssueMode { + Api, + Convenience, +} + +async fn list(args: IssueListArgs, mode: IssueMode) -> Result<()> { let session = crate::util::load_session_with_refresh().await?; let pds = session .pds @@ -24,11 +42,23 @@ async fn list(args: IssueListArgs) -> Result<()> { .unwrap_or_else(|| "https://bsky.social".into()); let client = tangled_api::TangledClient::new(&pds); - let repo_filter_refs = if let Some(repo) = &args.repo { + let inferred; + let repo_arg = if args.repo.is_none() && mode == IssueMode::Convenience { + inferred = Some(crate::util::current_git_repo_context()?); + inferred.as_ref().map(|context| context.repo_spec()) + } else { + inferred = None; + args.repo.clone() + }; + + let repo_filter_refs = if let Some(repo) = &repo_arg { let (owner, name) = parse_repo_ref(repo, &session.handle); let info = client .get_repo_info(owner, name, Some(session.access_jwt.as_str())) .await?; + if let Some(context) = inferred.as_ref() { + validate_remote_matches_repo(context, &info)?; + } Some(vec![info.issue_repo_ref(), info.repo_at_uri()]) } else { None @@ -125,7 +155,7 @@ fn parse_repo_at_uri(at_uri: &str) -> Option<(String, String)> { } } -async fn create(args: IssueCreateArgs) -> Result<()> { +async fn create(args: IssueCreateArgs, mode: IssueMode) -> Result<()> { let session = crate::util::load_session_with_refresh().await?; let pds = session .pds @@ -134,25 +164,36 @@ async fn create(args: IssueCreateArgs) -> Result<()> { .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 inferred = if args.repo.is_none() && mode == IssueMode::Convenience { + Some(crate::util::current_git_repo_context()?) + } else { + None + }; + let repo_spec; + let repo = if let Some(repo) = args.repo.as_ref() { + repo.as_str() + } else if let Some(context) = inferred.as_ref() { + repo_spec = context.repo_spec(); + repo_spec.as_str() + } else { + return Err(anyhow!("--repo is required for tangled api 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"))?; + if let Some(context) = inferred.as_ref() { + validate_remote_matches_repo(context, &info)?; + } + let title = resolve_issue_title(args.title.as_deref(), mode)?; + let body = resolve_issue_body(args.body.as_deref(), mode)?; let rkey = client .create_issue( &session.did, &info.issue_repo_ref(), &info.rkey, - title, - args.body.as_deref(), + title.as_str(), + body.as_deref(), &pds, &session.access_jwt, ) @@ -161,6 +202,57 @@ async fn create(args: IssueCreateArgs) -> Result<()> { Ok(()) } +fn resolve_issue_title(provided: Option<&str>, mode: IssueMode) -> Result { + if let Some(title) = provided { + return Ok(title.to_string()); + } + if mode == IssueMode::Convenience && std::io::stdin().is_terminal() { + return Input::new() + .with_prompt("Title") + .interact_text() + .map_err(Into::into); + } + Err(anyhow!( + "--title is required for tangled api issue create and non-interactive issue create" + )) +} + +fn resolve_issue_body(provided: Option<&str>, mode: IssueMode) -> Result> { + if let Some(body) = provided { + return Ok(Some(body.to_string())); + } + if mode == IssueMode::Convenience && std::io::stdin().is_terminal() { + let body = Input::::new() + .with_prompt("Body") + .allow_empty(true) + .interact_text()?; + if !body.trim().is_empty() { + return Ok(Some(body)); + } + } + Ok(None) +} + +fn validate_remote_matches_repo( + context: &crate::util::GitRepoContext, + info: &tangled_api::RepoRecord, +) -> Result<()> { + let host = context.host.as_str(); + let repo_knot = info.knot.as_str(); + let compatible = host == "tangled.org" && repo_knot == "knot1.tangled.sh" || host == repo_knot; + if compatible { + Ok(()) + } else { + Err(anyhow!( + "inferred remote {} points at {}, but PDS record for {} uses {}; pass --repo explicitly", + context.remote_name, + context.host, + context.repo_spec(), + info.knot + )) + } +} + async fn show(args: IssueShowArgs) -> Result<()> { // For now, show only accepts at-uri or did:rkey or rkey (for your DID) let session = crate::util::load_session_with_refresh().await?; diff --git a/crates/tangled-cli/src/commands/mod.rs b/crates/tangled-cli/src/commands/mod.rs index 107d783..3187485 100644 --- a/crates/tangled-cli/src/commands/mod.rs +++ b/crates/tangled-cli/src/commands/mod.rs @@ -25,7 +25,7 @@ async fn dispatch_api(cli: &Cli, cmd: ApiCommand) -> Result<()> { match cmd { ApiCommand::Auth(cmd) => auth::run(cli, cmd).await, ApiCommand::Repo(cmd) => repo::run(cli, cmd).await, - ApiCommand::Issue(cmd) => issue::run(cli, cmd).await, + ApiCommand::Issue(cmd) => issue::run_api(cli, cmd).await, ApiCommand::Pr(cmd) => pr::run_api(cli, cmd).await, ApiCommand::Knot(cmd) => knot::run(cli, cmd).await, ApiCommand::Spindle(cmd) => spindle::run(cli, cmd).await,