use crate::context::CommandContext;
use crate::display::{format_with_type_prefix, truncated_prefixes};
use crate::error::Result;
use crate::models::{Critique, CritiqueStatus, Priority, ProblemStatus, SolutionStatus};
use std::collections::HashMap;
fn priority_sort_value(priority: &Priority) -> i32 {
match priority {
Priority::Critical => 3,
Priority::High => 2,
Priority::Medium => 1,
Priority::Low => 0,
}
}
pub fn execute(
ctx: &CommandContext,
all: bool,
mine: bool,
limit: Option<usize>,
json: bool,
) -> Result<()> {
let store = &ctx.store;
let jj_client = ctx.jj();
let user = store.jj_client.user_identity().unwrap_or_default();
let problems = store.list_problems()?;
let solutions = store.list_solutions()?;
let critiques = store.list_critiques()?;
if json {
let current_change = jj_client.current_change_id().ok();
let active_solution = current_change
.as_ref()
.and_then(|change_id| solutions.iter().find(|s| s.change_ids.contains(change_id)));
let active_json = active_solution.map(|s| {
serde_json::json!({
"id": s.id,
"title": s.title,
"problem_id": s.problem_id,
"status": format!("{}", s.status),
})
});
let mut items = build_next_actions(&problems, &solutions, &critiques, &user, mine);
let total_count = items.len();
let effective_limit = if all { usize::MAX } else { limit.unwrap_or(5) };
items.truncate(effective_limit);
let open_problems = problems.iter().filter(|p| p.is_open()).count();
let review_solutions = solutions
.iter()
.filter(|s| s.status == SolutionStatus::Submitted)
.count();
let open_critiques = critiques
.iter()
.filter(|c| c.status == CritiqueStatus::Open)
.count();
let overlaps = super::overlaps::find_overlaps(ctx)?;
let overlaps_json: Vec<serde_json::Value> = overlaps
.iter()
.map(|o| {
serde_json::json!({
"file": o.file.to_string_lossy(),
"solutions": o.solutions.iter().map(|(id, title)| {
serde_json::json!({ "id": id, "title": title })
}).collect::<Vec<_>>(),
})
})
.collect();
let output = serde_json::json!({
"active_solution": active_json,
"items": items,
"total_count": total_count,
"user": user,
"summary": {
"open_problems": open_problems,
"review_solutions": review_solutions,
"open_critiques": open_critiques,
},
"overlaps": overlaps_json,
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
let all_uuids: Vec<&str> = problems
.iter()
.map(|p| p.id.as_str())
.chain(solutions.iter().map(|s| s.id.as_str()))
.chain(critiques.iter().map(|c| c.id.as_str()))
.collect();
let prefix_list = truncated_prefixes(&all_uuids);
let prefix_map: HashMap<&str, &str> = prefix_list
.iter()
.map(|(uuid, prefix)| (uuid.as_str(), prefix.as_str()))
.collect();
let display_id = |entity_type: &str, id: &str| -> String {
let prefix = prefix_map.get(id).copied().unwrap_or(id);
format_with_type_prefix(entity_type, prefix)
};
let current_change = jj_client.current_change_id().ok();
if let Some(change_id) = ¤t_change {
if let Some(active) = solutions.iter().find(|s| s.change_ids.contains(change_id)) {
let solution_display = display_id("solution", &active.id);
let problem_display = display_id("problem", &active.problem_id);
println!(
"Active: {} \"{}\" -> {} [{}]",
solution_display, active.title, problem_display, active.status
);
let active_critiques: Vec<_> = critiques
.iter()
.filter(|c| c.solution_id == active.id && c.status == CritiqueStatus::Open)
.collect();
if !active_critiques.is_empty() {
println!(" Open critiques: {}", active_critiques.len());
for c in &active_critiques {
let critique_display = display_id("critique", &c.id);
println!(" {}: {} [{}]", critique_display, c.title, c.severity);
}
}
println!();
}
}
let mut items = build_next_actions(&problems, &solutions, &critiques, &user, mine);
let total_count = items.len();
let effective_limit = if all { usize::MAX } else { limit.unwrap_or(5) };
items.truncate(effective_limit);
if items.is_empty() {
println!("No pending actions. All caught up!");
} else {
println!("Next actions:\n");
for (i, item) in items.iter().enumerate() {
let category = item["category"].as_str().unwrap_or("").to_uppercase();
let entity_type = item["entity_type"].as_str().unwrap_or("");
let entity_id = item["entity_id"].as_str().unwrap_or("");
let title = item["title"].as_str().unwrap_or("");
let summary = item["summary"].as_str().unwrap_or("");
let entity_display = display_id(entity_type, entity_id);
println!(
"{}. [{}] {}: {} -- {}",
i + 1,
category,
entity_display,
title,
summary
);
if let Some(details) = item["details"].as_array() {
for detail in details {
let detail_id = detail["id"].as_str().unwrap_or("");
let text = detail["text"].as_str().unwrap_or("");
let severity = detail["severity"].as_str().unwrap_or("");
let detail_display = display_id("critique", detail_id);
println!(" {}: {} [{}]", detail_display, text, severity);
}
}
if let Some(cmd) = item["suggested_command"].as_str() {
if !cmd.is_empty() {
println!(" -> {}", cmd);
}
}
println!();
}
if !all && total_count > effective_limit {
println!(
"Showing {} of {} items. Use --all to see everything.",
effective_limit, total_count
);
}
}
let open_problems = problems
.iter()
.filter(|p| p.status == ProblemStatus::Open || p.status == ProblemStatus::InProgress)
.count();
let review_solutions = solutions
.iter()
.filter(|s| s.status == SolutionStatus::Submitted)
.count();
let open_critiques = critiques
.iter()
.filter(|c| c.status == CritiqueStatus::Open)
.count();
println!(
"\nSummary: {} open problems, {} in review, {} open critiques",
open_problems, review_solutions, open_critiques
);
let overlaps = super::overlaps::find_overlaps(ctx)?;
if !overlaps.is_empty() {
let total_files: usize = overlaps.len();
println!(
"\nâš {} file{} touched by multiple active solutions:",
total_files,
if total_files == 1 { "" } else { "s" }
);
for overlap in &overlaps {
let sol_names: Vec<&str> = overlap
.solutions
.iter()
.map(|(_, title)| title.as_str())
.collect();
println!(" {} — {}", overlap.file.display(), sol_names.join(", "));
}
}
}
Ok(())
}
pub(crate) fn build_next_actions(
problems: &[crate::models::Problem],
solutions: &[crate::models::Solution],
critiques: &[Critique],
user: &str,
mine: bool,
) -> Vec<serde_json::Value> {
let mut items: Vec<serde_json::Value> = Vec::new();
for solution in solutions.iter().filter(|s| s.is_active()) {
let open_critiques: Vec<&Critique> = critiques
.iter()
.filter(|c| {
c.solution_id == solution.id
&& matches!(c.status, CritiqueStatus::Open | CritiqueStatus::Valid)
})
.collect();
if !open_critiques.is_empty() {
let top_critique = open_critiques
.iter()
.max_by_key(|c| c.severity.clone())
.unwrap();
let problem = problems.iter().find(|p| p.id == solution.problem_id);
let priority = problem.map(|p| &p.priority).cloned().unwrap_or_default();
let mut severity_counts: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for c in &open_critiques {
*severity_counts
.entry(format!("{}", c.severity))
.or_insert(0) += 1;
}
let severity_order = ["critical", "high", "medium", "low"];
let parts: Vec<String> = severity_order
.iter()
.filter_map(|s| severity_counts.get(*s).map(|n| format!("{} {}", n, s)))
.collect();
let summary = if parts.is_empty() {
format!("{} open critique(s)", open_critiques.len())
} else {
format!(
"{} critique{}: {}",
open_critiques.len(),
if open_critiques.len() == 1 { "" } else { "s" },
parts.join(", ")
)
};
items.push(serde_json::json!({
"category": "blocked",
"entity_type": "solution",
"entity_id": solution.id,
"title": solution.title,
"summary": summary,
"suggested_command": format!("jjj critique show {}", top_critique.id),
"priority": format!("{}", priority),
"priority_sort": priority_sort_value(&priority),
"created_at": solution.created_at.to_rfc3339(),
"details": open_critiques.iter().map(|c| serde_json::json!({
"id": c.id,
"text": c.title,
"severity": format!("{}", c.severity),
})).collect::<Vec<_>>(),
}));
}
}
for solution in solutions.iter().filter(|s| s.is_active()) {
let has_open = critiques.iter().any(|c| {
c.solution_id == solution.id
&& matches!(c.status, CritiqueStatus::Open | CritiqueStatus::Valid)
});
if !has_open && !solution.critique_ids.is_empty() {
let problem = problems.iter().find(|p| p.id == solution.problem_id);
let priority = problem.map(|p| &p.priority).cloned().unwrap_or_default();
items.push(serde_json::json!({
"category": "ready",
"entity_type": "solution",
"entity_id": solution.id,
"title": solution.title,
"summary": "All critiques resolved",
"suggested_command": format!("jjj solution approve {}", solution.id),
"priority": format!("{}", priority),
"priority_sort": priority_sort_value(&priority),
"created_at": solution.created_at.to_rfc3339(),
"details": [],
}));
}
}
if !mine {
for critique in critiques
.iter()
.filter(|c| c.status == CritiqueStatus::Open)
{
if let Some(reviewer) = &critique.reviewer {
if user.contains(reviewer) || reviewer.contains(user) {
let solution = solutions.iter().find(|s| s.id == critique.solution_id);
let problem =
solution.and_then(|s| problems.iter().find(|p| p.id == s.problem_id));
let priority = problem.map(|p| &p.priority).cloned().unwrap_or_default();
items.push(serde_json::json!({
"category": "review",
"entity_type": "critique",
"entity_id": critique.id,
"title": critique.title,
"summary": format!("Review requested on {}", critique.solution_id),
"suggested_command": format!("jjj critique show {}", critique.id),
"priority": format!("{}", priority),
"priority_sort": priority_sort_value(&priority),
"created_at": critique.created_at.to_rfc3339(),
"details": [],
}));
}
}
}
}
for solution in solutions.iter().filter(|s| s.is_active()) {
let is_mine = solution
.assignee
.as_ref()
.map(|a| user == *a)
.unwrap_or(false);
if is_mine {
let pending_reviews: Vec<_> = critiques
.iter()
.filter(|c| c.solution_id == solution.id && c.status == CritiqueStatus::Open)
.filter(|c| {
c.reviewer.is_some()
&& c.reviewer
.as_ref()
.map(|r| !user.contains(r))
.unwrap_or(false)
})
.collect();
if !pending_reviews.is_empty() {
let problem = problems.iter().find(|p| p.id == solution.problem_id);
let priority = problem.map(|p| &p.priority).cloned().unwrap_or_default();
let reviewers: Vec<_> = pending_reviews
.iter()
.filter_map(|c| c.reviewer.as_ref())
.map(|r| format!("@{}", r))
.collect();
items.push(serde_json::json!({
"category": "waiting",
"entity_type": "solution",
"entity_id": solution.id,
"title": solution.title,
"summary": format!("Awaiting review from {}", reviewers.join(", ")),
"suggested_command": "",
"priority": format!("{}", priority),
"priority_sort": priority_sort_value(&priority),
"created_at": solution.created_at.to_rfc3339(),
"details": [],
}));
}
}
}
for problem in problems.iter().filter(|p| p.is_open()) {
let has_active_solution = solutions
.iter()
.any(|s| s.problem_id == problem.id && s.is_active());
if !has_active_solution {
items.push(serde_json::json!({
"category": "todo",
"entity_type": "problem",
"entity_id": problem.id,
"title": problem.title,
"summary": "No solutions proposed",
"suggested_command": format!("jjj solution new \"title\" --problem {}", problem.id),
"priority": format!("{}", problem.priority),
"priority_sort": priority_sort_value(&problem.priority),
"created_at": problem.created_at.to_rfc3339(),
"details": [],
}));
}
}
items.sort_by(|a, b| {
let cat_order = |cat: &str| -> i32 {
match cat {
"blocked" => 0,
"ready" => 1,
"review" => 2,
"waiting" => 3,
"todo" => 4,
_ => 5,
}
};
let a_cat = cat_order(a["category"].as_str().unwrap_or(""));
let b_cat = cat_order(b["category"].as_str().unwrap_or(""));
if a_cat != b_cat {
return a_cat.cmp(&b_cat);
}
let a_pri = a["priority_sort"].as_i64().unwrap_or(0);
let b_pri = b["priority_sort"].as_i64().unwrap_or(0);
if a_pri != b_pri {
return b_pri.cmp(&a_pri);
}
let a_ts = a["created_at"].as_str().unwrap_or("");
let b_ts = b["created_at"].as_str().unwrap_or("");
a_ts.cmp(b_ts)
});
items
}