use crate::commands::github_list::{
format_relative_time, print_table, split_flexible_width, terminal_width, CellTone, TableCell,
TableColumn, TruncationMode,
};
use crate::commands::open::open_url_in_browser;
use crate::config::Config;
use crate::engine::Stack;
use crate::forge::{ForgeClient, RepoPrListItem};
use crate::git::GitRepo;
use crate::remote::RemoteInfo;
use anyhow::{bail, Context, Result};
use colored::Colorize;
use std::io::Write;
use std::process::Command;
use termimad::MadSkin;
const TITLE_MIN_WIDTH: usize = 24;
const BRANCH_MIN_WIDTH: usize = 18;
const BRANCH_MAX_WIDTH: usize = 36;
#[allow(dead_code)]
pub fn run() -> Result<()> {
run_open()
}
pub fn run_open() -> Result<()> {
let repo = GitRepo::open()?;
let current = repo.current_branch()?;
let stack = Stack::load(&repo)?;
let config = Config::load()?;
let branch_info = stack.branches.get(¤t);
if branch_info.is_none() {
anyhow::bail!(
"Branch '{}' is not tracked. Use {} to track it first.",
current,
"stax branch track".cyan()
);
}
let pr_number = super::resolve_pr::resolve_pr_number(&repo, &stack, ¤t, &config)?;
if pr_number.is_none() {
anyhow::bail!(
"No PR found for branch '{}'. Use {} to create one.",
current,
"stax submit".cyan()
);
}
let pr_number = pr_number.unwrap();
let remote_info = RemoteInfo::from_repo(&repo, &config)?;
let pr_url = remote_info.pr_url(pr_number);
println!("Opening {} in browser...", pr_url.cyan());
open_url_in_browser(&pr_url);
Ok(())
}
pub fn run_list(
limit: u8,
json: bool,
ready: bool,
all: bool,
current: bool,
stack: bool,
plain: bool,
) -> Result<()> {
if ready {
return crate::commands::ready::run(
crate::commands::ready::ReadyScopeMode::from_flags(all, current, stack),
json,
plain,
);
}
let repo = GitRepo::open()?;
let config = Config::load()?;
let remote_info = RemoteInfo::from_repo(&repo, &config)?;
let repo_label = format!("{}/{}", remote_info.namespace, remote_info.repo);
let rt = tokio::runtime::Runtime::new()?;
let prs = rt.block_on(async {
let client = ForgeClient::new(&remote_info)?;
client.list_open_pull_requests(limit).await
})?;
if json {
println!("{}", serde_json::to_string_pretty(&prs)?);
return Ok(());
}
print_pr_table(&repo_label, &prs);
Ok(())
}
pub fn run_body(edit: bool) -> Result<()> {
let repo = GitRepo::open()?;
let current = repo.current_branch()?;
let stack = Stack::load(&repo)?;
let config = Config::load()?;
let branch_info = stack.branches.get(¤t);
if branch_info.is_none() {
anyhow::bail!(
"Branch '{}' is not tracked. Use {} to track it first.",
current,
"stax branch track".cyan()
);
}
let pr_number = super::resolve_pr::resolve_pr_number(&repo, &stack, ¤t, &config)?;
if pr_number.is_none() {
anyhow::bail!(
"No PR found for branch '{}'. Use {} to create one.",
current,
"stax submit".cyan()
);
}
let pr_number = pr_number.unwrap();
let remote_info = RemoteInfo::from_repo(&repo, &config)?;
let rt = tokio::runtime::Runtime::new()?;
let _enter = rt.enter();
let client = ForgeClient::new(&remote_info)?;
let body = rt.block_on(async { client.get_pr_body(pr_number).await })?;
if edit {
let updated = edit_body(&body)?;
if updated == body {
println!("PR #{} body unchanged", pr_number.to_string().cyan());
return Ok(());
}
rt.block_on(async { client.update_pr_body(pr_number, &updated).await })?;
println!("Updated PR #{} body", pr_number.to_string().cyan());
return Ok(());
}
print_rendered_body(&body);
Ok(())
}
fn print_rendered_body(body: &str) {
let body = body.trim();
if body.is_empty() {
return;
}
let skin = MadSkin::default();
let rendered = skin.term_text(body);
println!("{}", rendered);
}
fn edit_body(body: &str) -> Result<String> {
let editor =
std::env::var("EDITOR").context("$EDITOR is not set; set EDITOR to edit PR body")?;
if editor.trim().is_empty() {
bail!("$EDITOR is empty; set EDITOR to edit PR body");
}
let mut file = tempfile::Builder::new()
.prefix("stax-pr-body-")
.suffix(".md")
.tempfile()
.context("Failed to create temporary PR body file")?;
file.write_all(body.as_bytes())
.context("Failed to write PR body to temporary file")?;
file.flush()
.context("Failed to flush temporary PR body file")?;
let path = file.path().to_path_buf();
let status = if cfg!(windows) {
Command::new("cmd")
.args(["/C", &format!("{} \"{}\"", editor, path.display())])
.status()
} else {
Command::new("sh")
.arg("-c")
.arg(format!("{} \"$1\"", editor))
.arg("stax-editor")
.arg(&path)
.status()
}
.context("Failed to launch $EDITOR")?;
if !status.success() {
bail!("$EDITOR exited with status {}", status);
}
std::fs::read_to_string(&path).context("Failed to read edited PR body")
}
fn print_pr_table(repo_label: &str, prs: &[RepoPrListItem]) {
let branch_strings: Vec<String> = prs.iter().map(|pr| pr.head_branch.clone()).collect();
let created_strings: Vec<String> = prs
.iter()
.map(|pr| format_relative_time(pr.created_at))
.collect();
let state_strings: Vec<String> = prs
.iter()
.map(|pr| {
if pr.is_draft {
"draft".to_string()
} else {
pr.state.to_lowercase()
}
})
.collect();
let id_width = prs
.iter()
.map(|pr| format!("#{}", pr.number).len())
.max()
.unwrap_or(2)
.max("ID".len());
let state_width = state_strings
.iter()
.map(|value| value.len())
.max()
.unwrap_or("STATE".len())
.max("STATE".len());
let created_width = created_strings
.iter()
.map(|value| value.len())
.max()
.unwrap_or("CREATED".len())
.max("CREATED".len());
let branch_pref = branch_strings
.iter()
.map(|value| value.len())
.max()
.unwrap_or("BRANCH".len())
.clamp(BRANCH_MIN_WIDTH, BRANCH_MAX_WIDTH);
let width = terminal_width().max(80);
let fixed_width = id_width + state_width + created_width + 8;
let flex_width = width.saturating_sub(fixed_width);
let (title_width, branch_width) = split_flexible_width(
flex_width,
TITLE_MIN_WIDTH,
branch_pref,
BRANCH_MIN_WIDTH,
BRANCH_MAX_WIDTH,
);
let columns = vec![
TableColumn {
header: "ID",
width: id_width,
},
TableColumn {
header: "STATE",
width: state_width,
},
TableColumn {
header: "TITLE",
width: title_width,
},
TableColumn {
header: "BRANCH",
width: branch_width,
},
TableColumn {
header: "CREATED",
width: created_width,
},
];
let rows = prs
.iter()
.zip(state_strings.iter())
.zip(branch_strings.iter())
.zip(created_strings.iter())
.map(|(((pr, state), branch), created)| {
vec![
TableCell {
text: format!("#{}", pr.number),
tone: CellTone::Id,
truncation: TruncationMode::None,
},
TableCell {
text: state.clone(),
tone: if pr.is_draft {
CellTone::StateDraft
} else {
CellTone::StateOpen
},
truncation: TruncationMode::None,
},
TableCell {
text: pr.title.clone(),
tone: CellTone::Default,
truncation: TruncationMode::End,
},
TableCell {
text: branch.clone(),
tone: CellTone::Branch,
truncation: TruncationMode::Middle,
},
TableCell {
text: created.clone(),
tone: CellTone::Secondary,
truncation: TruncationMode::None,
},
]
})
.collect::<Vec<_>>();
print_table(
repo_label,
&format!("{} open pull requests", prs.len()),
"No open pull requests.",
&columns,
&rows,
);
}