#![warn(
clippy::unwrap_used,
clippy::redundant_clone,
clippy::too_many_lines,
clippy::excessive_nesting,
)]
use std::env;
use std::io::IsTerminal;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use std::collections::HashMap;
use jjpr::cli::{AuthCommands, Cli, Commands, ConfigCommands};
use jjpr::config;
use jjpr::forge::remote;
use jjpr::forge::types::{ChecksStatus, MergeMethod, PrMergeability, PullRequest, RepoInfo, ReviewSummary};
use jjpr::forge::{AuthScheme, Forge, ForgeClient, ForgejoForge, ForgeKind, GitHubForge, GitLabForge, PaginationStyle};
use jjpr::forge::token as forge_token;
use jjpr::graph::change_graph;
use jjpr::jj::{Jj, JjRunner};
use jjpr::merge;
use jjpr::submit::{analyze, execute, plan, resolve};
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Commands::Submit {
bookmark,
reviewer,
remote,
draft,
ready,
base,
}) => {
let draft_mode = match (draft, ready) {
(true, _) => DraftMode::Draft,
(_, true) => DraftMode::Ready,
_ => DraftMode::Normal,
};
cmd_submit(SubmitOptions {
bookmark: bookmark.as_deref(),
reviewers: &reviewer,
preferred_remote: remote.as_deref(),
dry_run: cli.dry_run,
no_fetch: cli.no_fetch,
draft_mode,
base_override: base.as_deref(),
})
}
Some(Commands::Status { .. }) => cmd_stack_overview(cli.no_fetch),
Some(Commands::Merge {
bookmark,
merge_method,
required_approvals,
no_ci_check,
remote,
base,
watch,
}) => {
let ci_override = if no_ci_check { Some(false) } else { None };
cmd_merge(
MergeArgs {
bookmark: bookmark.as_deref(),
merge_method,
required_approvals,
ci_pass_override: ci_override,
preferred_remote: remote.as_deref(),
base_override: base.as_deref(),
watch,
},
cli.dry_run,
cli.no_fetch,
)
}
Some(Commands::Auth { command }) => {
match command {
AuthCommands::Test => {
let Some(detected) = detect_forge_for_cwd() else {
anyhow::bail!(
"could not detect forge. Run from a jj repo with a supported remote, \
or set forge = \"...\" in .jj/jjpr.toml"
);
};
print_forge_detection(&detected);
let forge = build_forge(
detected.kind,
detected.host.as_deref(),
detected.token,
detected.token_env_var.as_deref(),
)?;
jjpr::auth::test_auth(forge.as_ref())
}
AuthCommands::Setup => {
match detect_forge_for_cwd() {
Some(detected) => {
print_forge_detection(&detected);
jjpr::auth::print_auth_help(detected.kind);
}
None => jjpr::auth::print_auth_help_all(),
}
Ok(())
}
}
}
Some(Commands::Config { command }) => match command {
ConfigCommands::Init { repo } => {
if repo {
cmd_config_init_repo()
} else {
cmd_config_init()
}
}
},
None => cmd_stack_overview(cli.no_fetch),
}
}
enum DraftMode {
Normal,
Draft,
Ready,
}
struct SubmitOptions<'a> {
bookmark: Option<&'a str>,
reviewers: &'a [String],
preferred_remote: Option<&'a str>,
dry_run: bool,
no_fetch: bool,
draft_mode: DraftMode,
base_override: Option<&'a str>,
}
fn cmd_submit(opts: SubmitOptions<'_>) -> Result<()> {
let repo_path = find_repo_root()?;
let jj = JjRunner::new(repo_path.clone())?;
let cfg = config::load_config_with_repo(Some(&repo_path))?;
let target_bookmark = match opts.bookmark {
Some(name) => name.to_string(),
None => {
let graph = change_graph::build_change_graph(&jj)?;
match analyze::infer_target_bookmark(&graph, &jj)? {
Some(inferred) => {
println!("Submitting stack for '{inferred}' (inferred from working copy)\n");
inferred
}
None => {
println!("No bookmark found in the working copy's ancestry.");
println!("Set a bookmark with `jj bookmark set <name>` or specify one: `jjpr submit <bookmark>`");
return Ok(());
}
}
}
};
if !opts.no_fetch {
eprintln!("Fetching remotes...");
jj.git_fetch()?;
}
let remotes = jj.get_git_remotes()?;
let resolved = resolve_forge(&remotes, &cfg, opts.preferred_remote)?;
let ResolvedForge { forge, kind: forge_kind, remote_name, repo_info } = resolved;
let default_branch = jj.get_default_branch()?;
let graph = change_graph::build_change_graph(&jj)?;
let analysis = analyze::analyze_submission_graph(&graph, &target_bookmark)?;
let interactive = std::io::stdout().is_terminal();
let segments = resolve::resolve_bookmark_selections(&analysis.relevant_segments, interactive)?;
let stack_base = opts.base_override.or(analysis.base_branch.as_deref());
let submission_plan = plan::create_submission_plan(
forge.as_ref(),
&segments,
&remote_name,
&repo_info,
forge_kind,
&default_branch,
matches!(opts.draft_mode, DraftMode::Draft),
matches!(opts.draft_mode, DraftMode::Ready),
opts.reviewers,
stack_base,
)?;
if opts.bookmark.is_some() {
println!("Submitting stack for '{target_bookmark}'...\n");
}
execute::execute_submission_plan(&jj, forge.as_ref(), &submission_plan, opts.reviewers, opts.dry_run)?;
println!("\nDone.");
Ok(())
}
fn cmd_stack_overview(no_fetch: bool) -> Result<()> {
let repo_path = find_repo_root()?;
let jj = JjRunner::new(repo_path.clone())?;
let cfg = config::load_config_with_repo(Some(&repo_path))?;
if !no_fetch {
eprintln!("Fetching remotes...");
jj.git_fetch()?;
}
let graph = change_graph::build_change_graph(&jj)?;
if graph.stacks.is_empty() {
println!("No stacks found. Create bookmarks with `jj bookmark set <name>`.");
return Ok(());
}
let info = try_load_pr_info(&jj, &cfg, &graph).unwrap_or(PrInfoResult {
forge_kind: ForgeKind::GitHub,
pr_map: HashMap::new(),
forge: None,
repo_info: None,
});
let mut status_map: HashMap<String, SegmentDisplayStatus> = HashMap::new();
if let (Some(forge), Some(repo_info)) = (&info.forge, &info.repo_info) {
for stack in &graph.stacks {
for segment in &stack.segments {
if let Some(bookmark) = segment.bookmarks.first()
&& let Some(pr) = info.pr_map.get(&bookmark.name)
{
status_map.insert(
bookmark.name.clone(),
fetch_segment_status(forge.as_ref(), repo_info, pr),
);
}
}
}
}
let multi = graph.stacks.len() > 1;
for (i, stack) in graph.stacks.iter().enumerate() {
if i > 0 {
println!();
}
if multi {
println!("Stack {}:", i + 1);
}
for segment in &stack.segments {
let bookmark_names: Vec<&str> =
segment.bookmarks.iter().map(|b| b.name.as_str()).collect();
let name = bookmark_names.join(", ");
let sync_status = if segment.bookmarks.iter().all(|b| b.is_synced) {
"synced"
} else {
"needs push"
};
let change_count = segment.changes.len();
let pr_label = segment
.bookmarks
.first()
.and_then(|b| info.pr_map.get(&b.name))
.map(|pr| {
let state = if pr.draft { "draft" } else { "open" };
format!(", {} {state}", info.forge_kind.format_ref(pr.number))
})
.unwrap_or_default();
let merge_label = if segment.merge_source_names.is_empty() {
String::new()
} else {
format!(", merge of {}", segment.merge_source_names.join(" + "))
};
println!(
" {} ({} change{}{}{}, {})",
name,
change_count,
if change_count == 1 { "" } else { "s" },
merge_label,
pr_label,
sync_status
);
if let Some(bookmark) = segment.bookmarks.first()
&& let Some(pr) = info.pr_map.get(&bookmark.name)
&& let Some(status) = status_map.get(&bookmark.name)
{
let line = format_status_line(status, pr.draft);
if !line.is_empty() {
println!("{line}");
}
}
}
if let Some(base) = &stack.base_branch {
println!(" (based on {base})");
}
}
Ok(())
}
struct PrInfoResult {
forge_kind: ForgeKind,
pr_map: HashMap<String, PullRequest>,
forge: Option<Box<dyn Forge>>,
repo_info: Option<RepoInfo>,
}
fn try_load_pr_info(
jj: &dyn Jj,
cfg: &config::Config,
graph: &change_graph::ChangeGraph,
) -> Option<PrInfoResult> {
let remotes = jj.get_git_remotes().ok()?;
let resolved = resolve_forge(&remotes, cfg, None).ok()?;
let ResolvedForge { forge, kind, repo_info, .. } = resolved;
let all_prs = match forge.list_open_prs(&repo_info.owner, &repo_info.repo) {
Ok(prs) => prs,
Err(_) => {
if !graph.stacks.is_empty() && forge.get_authenticated_user().is_err() {
eprintln!("hint: run `jjpr auth test` to check authentication for stack overview");
}
return Some(PrInfoResult {
forge_kind: kind,
pr_map: HashMap::new(),
forge: None,
repo_info: None,
});
}
};
let pr_map = jjpr::forge::build_pr_map(all_prs, &repo_info.owner);
Some(PrInfoResult {
forge_kind: kind,
pr_map,
forge: Some(forge),
repo_info: Some(repo_info),
})
}
struct SegmentDisplayStatus {
mergeability: Option<PrMergeability>,
checks: Option<ChecksStatus>,
reviews: Option<ReviewSummary>,
}
fn fetch_segment_status(
forge: &dyn Forge,
repo_info: &RepoInfo,
pr: &PullRequest,
) -> SegmentDisplayStatus {
let mergeability = forge
.get_pr_mergeability(&repo_info.owner, &repo_info.repo, pr.number)
.ok();
let checks = forge
.get_pr_checks_status(&repo_info.owner, &repo_info.repo, &pr.head.ref_name)
.ok();
let reviews = forge
.get_pr_reviews(&repo_info.owner, &repo_info.repo, pr.number)
.ok();
SegmentDisplayStatus { mergeability, checks, reviews }
}
fn format_status_line(status: &SegmentDisplayStatus, is_draft: bool) -> String {
if is_draft {
return " \u{2014} draft".to_string();
}
let mut parts = Vec::new();
if let Some(m) = &status.mergeability {
match m.mergeable {
Some(true) => parts.push("\u{2713} mergeable".to_string()),
Some(false) => parts.push("\u{2717} conflicts".to_string()),
None => parts.push("\u{2014} mergeability computing".to_string()),
}
}
if let Some(checks) = &status.checks {
match checks {
ChecksStatus::Pass => parts.push("\u{2713} CI passing".to_string()),
ChecksStatus::Fail => parts.push("\u{2717} CI failing".to_string()),
ChecksStatus::Pending => parts.push("\u{2717} CI pending".to_string()),
ChecksStatus::None => {}
}
}
if let Some(r) = &status.reviews {
if r.changes_requested {
parts.push("\u{26a0} changes requested".to_string());
}
parts.push(format!(
"{} {} approval{}",
if r.approved_count > 0 { "\u{2713}" } else { "\u{2717}" },
r.approved_count,
if r.approved_count == 1 { "" } else { "s" },
));
}
if parts.is_empty() {
return String::new();
}
format!(" {}", parts.join(" "))
}
struct MergeArgs<'a> {
bookmark: Option<&'a str>,
merge_method: Option<MergeMethod>,
required_approvals: Option<u32>,
ci_pass_override: Option<bool>,
preferred_remote: Option<&'a str>,
base_override: Option<&'a str>,
watch: bool,
}
fn cmd_merge(args: MergeArgs<'_>, dry_run: bool, no_fetch: bool) -> Result<()> {
let repo_path = find_repo_root()?;
let jj = JjRunner::new(repo_path.clone())?;
let cfg = config::load_config_with_repo(Some(&repo_path))?;
let target_bookmark = match args.bookmark {
Some(name) => name.to_string(),
None => {
let graph = change_graph::build_change_graph(&jj)?;
match analyze::infer_target_bookmark(&graph, &jj)? {
Some(inferred) => {
println!("Merging stack for '{inferred}' (inferred from working copy)\n");
inferred
}
None => {
println!("No bookmark found in the working copy's ancestry.");
println!("Set a bookmark with `jj bookmark set <name>` or specify one: `jjpr merge <bookmark>`");
return Ok(());
}
}
}
};
if !no_fetch {
eprintln!("Fetching remotes...");
jj.git_fetch()?;
}
let remotes = jj.get_git_remotes()?;
let resolved = resolve_forge(&remotes, &cfg, args.preferred_remote)?;
let ResolvedForge { forge, kind: forge_kind, remote_name, repo_info } = resolved;
let default_branch = jj.get_default_branch()?;
let graph = change_graph::build_change_graph(&jj)?;
let analysis = analyze::analyze_submission_graph(&graph, &target_bookmark)?;
let interactive = std::io::stdout().is_terminal();
let segments = resolve::resolve_bookmark_selections(&analysis.relevant_segments, interactive)?;
let merge_options = merge::plan::MergeOptions {
merge_method: args.merge_method.unwrap_or(cfg.merge_method),
required_approvals: args.required_approvals.unwrap_or(cfg.required_approvals),
require_ci_pass: args.ci_pass_override.unwrap_or(cfg.require_ci_pass),
};
let stack_base = args.base_override.or(analysis.base_branch.as_deref());
let merge_plan = merge::plan::create_merge_plan(
forge.as_ref(),
&segments,
&repo_info,
forge_kind,
&default_branch,
&remote_name,
&merge_options,
stack_base,
)?;
if args.bookmark.is_some() {
println!("Merging stack for '{target_bookmark}'...\n");
}
let result = merge::execute::execute_merge_plan(
&jj, forge.as_ref(), &merge_plan, &segments, dry_run, args.watch,
)?;
if result.merged.is_empty() && result.skipped_merged.is_empty() && result.blocked_at.is_none() {
println!("\nNo PRs to merge in this stack.");
} else if let Some(ref blocked) = result.blocked_at {
if blocked.reasons.iter().all(|r| r.is_transient()) {
if args.watch {
println!("\nWatch timed out. Run `jjpr merge --watch` to resume waiting.");
} else {
println!("\nRun `jjpr merge --watch` to wait for CI and auto-continue.");
}
} else {
println!("\nRun `jjpr merge` again once the issue is resolved.");
}
} else if result.merged.is_empty() && !result.skipped_merged.is_empty() {
println!("\nAll PRs in this stack are already merged.");
} else {
println!("\nDone \u{2014} {} PR{} merged.", result.merged.len(), if result.merged.len() == 1 { "" } else { "s" });
}
if !result.local_warnings.is_empty() {
println!();
println!("Note: local state is out of sync with the forge:");
for w in &result.local_warnings {
println!(" {}", w.message);
}
let merged_names: std::collections::HashSet<&str> = result.merged.iter()
.map(|m| m.bookmark_name.as_str())
.chain(result.skipped_merged.iter().map(|s| s.bookmark_name.as_str()))
.collect();
let unmerged: Vec<_> = segments.iter()
.filter(|s| !merged_names.contains(s.bookmark.name.as_str()))
.collect();
println!();
println!("To accept the forge state (discard local divergence):");
println!(" jj git fetch");
for seg in &unmerged {
println!(" jj bookmark set {} -r {}@origin", seg.bookmark.name, seg.bookmark.name);
}
if let Some(first_unmerged) = unmerged.first() {
println!();
println!("Or to fix local state and push it to the forge:");
let base = stack_base.unwrap_or(&default_branch);
println!(" jj git fetch && jj rebase -s {} -d {base}",
first_unmerged.bookmark.change_id);
println!(" # resolve any conflicts, then:");
println!(" jjpr submit");
}
}
Ok(())
}
fn cmd_config_init() -> Result<()> {
let path = config::write_default_config()?;
println!("Created default config at {}", path.display());
println!("Edit it to customize merge behavior.");
Ok(())
}
fn cmd_config_init_repo() -> Result<()> {
let repo_path = find_repo_root()?;
let path = config::write_repo_config(&repo_path)?;
println!("Created repo config at {}", path.display());
println!("Edit it to set forge type and token configuration.");
Ok(())
}
struct ResolvedForge {
forge: Box<dyn Forge>,
kind: ForgeKind,
remote_name: String,
repo_info: RepoInfo,
}
fn resolve_forge(
remotes: &[jjpr::jj::GitRemote],
cfg: &config::Config,
preferred_remote: Option<&str>,
) -> Result<ResolvedForge> {
if let Some(kind) = cfg.forge {
resolve_forge_from_config(remotes, kind, cfg.forge_token_env.as_deref(), preferred_remote)
} else {
resolve_forge_auto(remotes, preferred_remote)
}
}
fn resolve_forge_from_config(
remotes: &[jjpr::jj::GitRemote],
kind: ForgeKind,
token_env: Option<&str>,
preferred_remote: Option<&str>,
) -> Result<ResolvedForge> {
let env_var = token_env.unwrap_or(kind.token_env_var());
let token = std::env::var(env_var).ok().filter(|v| !v.is_empty());
let remote = pick_remote(remotes, preferred_remote)?;
let host = remote::extract_host(&remote.url);
let repo_info = remote::parse_url_as(&remote.url, kind)
.ok_or_else(|| anyhow::anyhow!(
"could not parse owner/repo from remote '{}' URL: {}",
remote.name, remote.url
))?;
let forge = build_forge(kind, host, token, token_env)?;
Ok(ResolvedForge {
forge,
kind,
remote_name: remote.name.clone(),
repo_info,
})
}
fn resolve_forge_auto(
remotes: &[jjpr::jj::GitRemote],
preferred_remote: Option<&str>,
) -> Result<ResolvedForge> {
let (remote_name, kind, repo_info) = remote::resolve_remote(remotes, preferred_remote)?;
let host = find_remote_host(remotes, &remote_name);
let forge = build_forge(kind, host, None, None)?;
Ok(ResolvedForge {
forge,
kind,
remote_name,
repo_info,
})
}
fn pick_remote<'a>(
remotes: &'a [jjpr::jj::GitRemote],
preferred: Option<&str>,
) -> Result<&'a jjpr::jj::GitRemote> {
if let Some(name) = preferred {
return remotes
.iter()
.find(|r| r.name == name)
.ok_or_else(|| anyhow::anyhow!("remote '{}' not found", name));
}
if let Some(origin) = remotes.iter().find(|r| r.name == "origin") {
return Ok(origin);
}
remotes
.first()
.ok_or_else(|| anyhow::anyhow!("no git remotes found"))
}
fn find_remote_host<'a>(remotes: &'a [jjpr::jj::GitRemote], remote_name: &str) -> Option<&'a str> {
remotes
.iter()
.find(|r| r.name == remote_name)
.and_then(|r| remote::extract_host(&r.url))
}
fn build_forge(kind: ForgeKind, host: Option<&str>, token: Option<String>, token_env: Option<&str>) -> Result<Box<dyn Forge>> {
let token = match token {
Some(t) => t,
None => forge_token::resolve_token(kind, token_env)?,
};
match kind {
ForgeKind::GitHub => {
let client = ForgeClient::new("https://api.github.com", token, AuthScheme::Bearer, PaginationStyle::LinkHeader);
Ok(Box::new(GitHubForge::new(client)))
}
ForgeKind::GitLab => {
let gitlab_host = host.unwrap_or("gitlab.com");
let base_url = format!("https://{gitlab_host}/api/v4");
let client = ForgeClient::new(&base_url, token, AuthScheme::Bearer, PaginationStyle::LinkHeader);
Ok(Box::new(GitLabForge::new(client)))
}
ForgeKind::Forgejo => {
let host = host.ok_or_else(|| anyhow::anyhow!("could not determine Forgejo host from remote URL"))?;
let base_url = format!("https://{host}/api/v1");
let client = ForgeClient::new(&base_url, token, AuthScheme::Token, PaginationStyle::PageNumber { limit: 50 });
Ok(Box::new(ForgejoForge::new(client)))
}
}
}
fn print_forge_detection(detected: &DetectedForge) {
let source = match &detected.source {
ForgeSource::Config => "from config".to_string(),
ForgeSource::Remote(name) => format!("from remote '{name}'"),
};
println!("Detected forge: {} ({source})", detected.kind);
}
struct DetectedForge {
kind: ForgeKind,
host: Option<String>,
token: Option<String>,
token_env_var: Option<String>,
source: ForgeSource,
}
enum ForgeSource {
Config,
Remote(String),
}
fn detect_forge_for_cwd() -> Option<DetectedForge> {
let repo_path = find_repo_root().ok()?;
let cfg = config::load_config_with_repo(Some(&repo_path)).ok()?;
let jj = JjRunner::new(repo_path).ok()?;
let remotes = jj.get_git_remotes().ok()?;
if let Some(kind) = cfg.forge {
let host = pick_remote(&remotes, None)
.ok()
.and_then(|r| remote::extract_host(&r.url).map(|s| s.to_string()));
let env_var = cfg.forge_token_env.as_deref().unwrap_or(kind.token_env_var());
let token = std::env::var(env_var).ok();
return Some(DetectedForge {
kind,
host,
token,
token_env_var: Some(env_var.to_string()),
source: ForgeSource::Config,
});
}
let (remote_name, kind, _) = remote::resolve_remote(&remotes, None).ok()?;
let host = find_remote_host(&remotes, &remote_name).map(|s| s.to_string());
Some(DetectedForge { kind, host, token: None, token_env_var: None, source: ForgeSource::Remote(remote_name) })
}
fn find_repo_root() -> Result<PathBuf> {
let cwd = env::current_dir().context("failed to get current directory")?;
let mut path = cwd.as_path();
loop {
if path.join(".jj").is_dir() {
return Ok(path.to_path_buf());
}
match path.parent() {
Some(parent) => path = parent,
None => {
let mut check = cwd.as_path();
loop {
if check.join(".git").exists() {
anyhow::bail!(
"found a git repository but no jj repository. \
Run `jj git init --colocate` to set up jj alongside git."
);
}
match check.parent() {
Some(parent) => check = parent,
None => break,
}
}
anyhow::bail!(
"not a jj repository (or any parent up to /). \
Run `jj git init` to create one."
);
}
}
}
}