use std::io::IsTerminal;
use anyhow::{anyhow, Result};
use inquire::Text;
use crate::cli::{
Cli, IssueCommand, IssueCommentArgs, IssueCreateArgs, IssueEditArgs,
IssueListArgs, IssueShowArgs, OutputFormat,
};
use crate::ops::types::Issue;
pub async fn run(cli: &Cli, cmd: IssueCommand) -> Result<()> {
match cmd {
IssueCommand::List(args) => list(cli, args).await,
IssueCommand::Create(args) => create(args).await,
IssueCommand::Show(args) => show(cli, args).await,
IssueCommand::Edit(args) => edit(args).await,
IssueCommand::Comment(args) => comment(args).await,
}
}
async fn list(cli: &Cli, args: IssueListArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let inferred;
let repo_arg = if args.repo.is_none() {
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 =
crate::ops::repo::get_repo_info(&pds, owner, name, &auth).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
};
let mut items =
crate::ops::issue::list_issues(&pds, &session.did, None, &auth).await?;
if let Some(repo_refs) = repo_filter_refs.as_ref() {
items.retain(|it| {
repo_refs.iter().any(|repo_ref| repo_ref == &it.issue.repo)
});
}
if let Some(state_filter) = &args.state {
let state_nsid = match state_filter.as_str() {
"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 states =
crate::ops::issue::list_issue_states(&pds, &session.did, &auth)
.await?;
let mut issue_states = std::collections::HashMap::new();
for state in states {
issue_states.insert(state.issue, state.state);
}
items.retain(|it| {
let issue_at = format!(
"at://{}/sh.tangled.repo.issue/{}",
it.author_did, it.rkey
);
match issue_states.get(&issue_at) {
Some(state) => state == state_nsid,
None => state_nsid == "sh.tangled.repo.issue.state.open", }
});
}
if matches!(cli.format, OutputFormat::Json | OutputFormat::Yaml) {
return crate::util::print_serialized(cli.format, &items);
}
if items.is_empty() {
println!("No issues found (showing only issues you created)");
} else {
let mut repo_cache: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut rows = Vec::with_capacity(items.len());
for item in items {
let repo_ref = item.issue.repo.clone();
let repo_display = if let Some(cached) = repo_cache.get(&repo_ref) {
cached.clone()
} else {
let display =
crate::ops::repo::repo_display_name(&pds, &repo_ref, &auth)
.await
.unwrap_or_else(|_| repo_ref.clone());
repo_cache.insert(repo_ref, display.clone());
display
};
rows.push([item.rkey, item.issue.title, repo_display]);
}
crate::util::print_table(["RKEY", "TITLE", "REPOSITORY"], rows);
}
Ok(())
}
async fn create(args: IssueCreateArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let inferred = if args.repo.is_none() {
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 issue create outside a Tangled checkout"
));
};
let (owner, name) = parse_repo_ref(repo, &session.handle);
let info =
crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
if let Some(context) = inferred.as_ref() {
validate_remote_matches_repo(context, &info)?;
}
let title = resolve_issue_title(args.title.as_deref())?;
let body = resolve_issue_body(args.body.as_deref())?;
let rkey = crate::ops::issue::create_issue(
&pds,
&auth,
&session.did,
&info.issue_repo_ref(),
&info.rkey,
title.as_str(),
body.as_deref(),
)
.await?;
println!("Created issue rkey={} in {}/{}", rkey, owner, name);
Ok(())
}
fn resolve_issue_title(provided: Option<&str>) -> Result<String> {
if let Some(title) = provided {
return Ok(title.to_string());
}
if std::io::stdin().is_terminal() {
return Text::new("Title").prompt().map_err(Into::into);
}
Err(anyhow!(
"--title is required for non-interactive issue create"
))
}
fn resolve_issue_body(provided: Option<&str>) -> Result<Option<String>> {
if let Some(body) = provided {
return Ok(Some(body.to_string()));
}
if std::io::stdin().is_terminal() {
return crate::commands::prompt_body_editor_or_skip(None);
}
Ok(None)
}
fn validate_remote_matches_repo(
context: &crate::util::GitRepoContext,
info: &crate::ops::types::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(cli: &Cli, args: IssueShowArgs) -> Result<()> {
let format = if args.json {
OutputFormat::Json
} else {
cli.format
};
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
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 items = crate::ops::issue::list_issues(&pds, &did, None, &auth).await?;
if let Some(item) = items.into_iter().find(|i| i.rkey == rkey) {
if matches!(format, OutputFormat::Json | OutputFormat::Yaml) {
return crate::util::print_serialized(format, &item);
}
println!("TITLE: {}", item.issue.title);
if !item.issue.body.is_empty() {
println!("BODY:\n{}", item.issue.body);
}
println!("REPO: {}", item.issue.repo);
println!("AUTHOR: {}", item.author_did);
println!("RKEY: {}", rkey);
} else if matches!(format, OutputFormat::Json | OutputFormat::Yaml) {
crate::util::print_serialized(
format,
&Option::<crate::ops::types::IssueRecord>::None,
)?;
} else {
println!("Issue not found for did={} rkey={}", did, rkey);
}
Ok(())
}
async fn edit(args: IssueEditArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
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 mut rec: Issue =
crate::ops::issue::get_issue_record(&pds, &did, &rkey, &auth).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();
}
crate::ops::issue::put_issue_record(&pds, &did, &rkey, &rec, &auth).await?;
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 = format!("at://{}/sh.tangled.repo.issue/{}", did, rkey);
crate::ops::issue::set_issue_state(
&pds,
&auth,
&session.did,
&issue_at,
state_nsid,
)
.await?;
}
println!("Updated issue {}:{}", did, rkey);
Ok(())
}
async fn comment(args: IssueCommentArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
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 _issue =
crate::ops::issue::get_issue_record(&pds, &did, &rkey, &auth).await?;
let issue_at = format!("at://{}/sh.tangled.repo.issue/{}", did, rkey);
if let Some(body) = args.body.as_deref() {
crate::ops::issue::comment_issue(
&pds,
&auth,
&session.did,
&issue_at,
body,
)
.await?;
println!("Comment posted");
}
if args.close {
crate::ops::issue::set_issue_state(
&pds,
&auth,
&session.did,
&issue_at,
"sh.tangled.repo.issue.state.closed",
)
.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()))
}