1use std::collections::BTreeMap;
2
3use anyhow::Result;
4use clap::ValueEnum;
5
6use crate::commands::Run;
7use crate::providers::{
8 ReviewRequest, ReviewState, detect_review_provider, label, owned_review_for_branch,
9};
10use crate::{git, stack};
11
12fn review_numbers() -> BTreeMap<String, String> {
16 let Some((_, provider)) = detect_review_provider().ok() else {
17 return BTreeMap::new();
18 };
19 let Ok(reviews) = provider.open_reviews() else {
20 return BTreeMap::new();
21 };
22 reviews
23 .into_iter()
24 .map(|review| (review.branch, review.id))
25 .collect()
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
30pub enum Format {
31 Markdown,
33 Plain,
36}
37
38#[derive(Debug, clap::Args)]
40pub struct List {
41 #[arg(long, value_enum)]
43 format: Option<Format>,
44 #[arg(long, conflicts_with = "format")]
46 all: bool,
47 #[arg(long, conflicts_with = "format")]
49 commits: bool,
50}
51
52impl Run for List {
53 fn run(self) -> Result<()> {
54 match (self.format, self.all) {
55 (Some(format), _) => list_formatted(format),
56 (None, true) => crate::stack::print_all_stacks(&review_numbers(), self.commits),
57 (None, false) => crate::stack::print_stack(&review_numbers(), self.commits),
58 }
59 }
60}
61
62pub fn list_formatted(format: Format) -> Result<()> {
67 let current = git::current_branch()?;
68 let root = stack::stack_root(¤t)?;
69 let branches: Vec<String> = stack::branch_and_descendants(&root)?
70 .into_iter()
71 .skip(1) .collect();
73
74 if branches.is_empty() {
75 println!("no stacked branches");
76 return Ok(());
77 }
78
79 let review_provider = detect_review_provider().ok().map(|(_, client)| client);
80 let entries: Vec<(String, Option<ReviewRequest>)> = branches
81 .iter()
82 .map(|branch| {
83 let review = review_provider
84 .as_ref()
85 .and_then(|rp| owned_review_for_branch(&**rp, branch).ok().flatten());
86 (branch.clone(), review)
87 })
88 .collect();
89
90 println!("{}", summary(&entries, &root, format));
91 println!();
92 for (index, (branch, review)) in entries.iter().enumerate() {
93 let number = index + 1;
94 match (format, review) {
95 (Format::Markdown, Some(review)) => {
96 println!(
97 "{number}. [{}]({}) - {}",
98 labeled_with_size(review, branch),
99 review.url,
100 review.state
101 );
102 }
103 (Format::Markdown, None) => println!("{number}. `{branch}` (no review)"),
104 (Format::Plain, Some(review)) => {
106 println!(
107 "{number}. {} - {}",
108 labeled_with_size(review, branch),
109 review.state
110 );
111 println!(" {}", review.url);
112 }
113 (Format::Plain, None) => println!("{number}. {branch} (no review)"),
114 }
115 }
116
117 Ok(())
118}
119
120fn labeled_with_size(review: &ReviewRequest, branch: &str) -> String {
124 let id = match branch_diff_size(branch) {
125 Some((added, deleted)) => format!("{}, +{added}/-{deleted}", review.id),
126 None => review.id.clone(),
127 };
128 label(&review.title, &id)
129}
130
131fn branch_diff_size(branch: &str) -> Option<(usize, usize)> {
134 let parent = stack::parent_of(branch).ok().flatten()?;
135 let (added, deleted) = git::diff_numstat(&parent, branch).ok()?;
136 (added > 0 || deleted > 0).then_some((added, deleted))
137}
138
139fn summary(entries: &[(String, Option<ReviewRequest>)], base: &str, format: Format) -> String {
142 let total = entries.len();
143 let reviews: Vec<&ReviewRequest> = entries.iter().filter_map(|(_, r)| r.as_ref()).collect();
144 let base = match format {
145 Format::Markdown => format!("`{base}`"),
146 Format::Plain => base.to_owned(),
147 };
148
149 let mut summary = if reviews.is_empty() {
150 format!(
151 "{total} branch{}, base {base}",
152 if total == 1 { "" } else { "es" }
153 )
154 } else if reviews.len() == total {
155 format!(
156 "{total} PR{}, base {base}",
157 if total == 1 { "" } else { "s" }
158 )
159 } else {
160 format!(
161 "{total} branches ({} with reviews), base {base}",
162 reviews.len()
163 )
164 };
165
166 if !reviews.is_empty() {
167 let mut counts = Vec::new();
168 for (state, label) in [
169 (ReviewState::Open, "open"),
170 (ReviewState::Merged, "merged"),
171 (ReviewState::Closed, "closed"),
172 ] {
173 let count = reviews
174 .iter()
175 .filter(|review| review.state == state)
176 .count();
177 if count > 0 {
178 counts.push(format!("{count} {label}"));
179 }
180 }
181 if !counts.is_empty() {
182 summary.push_str(&format!(", {}", counts.join(" / ")));
183 }
184 }
185
186 summary
187}