1use std::collections::{BTreeMap, BTreeSet};
2
3use anyhow::Result;
4use clap::ValueEnum;
5
6use crate::commands::Run;
7use crate::providers::{
8 ReviewAnnotation, ReviewRequest, ReviewState, detect_review_provider, label,
9 owned_review_for_branch,
10};
11use crate::{git, stack};
12
13fn review_annotations(
20 listed: &BTreeSet<String>,
21 detail: bool,
22) -> BTreeMap<String, ReviewAnnotation> {
23 let Some((_, provider)) = detect_review_provider().ok() else {
24 return BTreeMap::new();
25 };
26 let branches: Vec<String> = listed.iter().cloned().collect();
27 provider
28 .annotate_branches(&branches, detail)
29 .unwrap_or_default()
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
34pub enum Format {
35 Markdown,
37 Plain,
40}
41
42#[derive(Debug, clap::Args)]
44pub struct List {
45 #[arg(long, value_enum)]
47 format: Option<Format>,
48 #[arg(long, conflicts_with = "format")]
50 all: bool,
51 #[arg(long, conflicts_with = "format")]
53 commits: bool,
54 #[arg(long, conflicts_with_all = ["format", "commits", "local"])]
56 reviews: bool,
57 #[arg(long, conflicts_with_all = ["format", "reviews"])]
60 local: bool,
61}
62
63impl Run for List {
64 fn run(self) -> Result<()> {
65 if let Some(format) = self.format {
66 return list_formatted(format);
67 }
68 let annotations = if self.local {
71 BTreeMap::new()
72 } else {
73 review_annotations(&stack::listed_branches(self.all)?, self.reviews)
74 };
75 if self.all {
76 stack::print_all_stacks(&annotations, self.commits)
77 } else {
78 stack::print_stack(&annotations, self.commits)
79 }
80 }
81}
82
83pub fn list_formatted(format: Format) -> Result<()> {
88 let current = git::current_branch()?;
89 let root = stack::stack_root(¤t)?;
90 let branches: Vec<String> = stack::current_stack_branches(¤t)?
94 .into_iter()
95 .filter(|branch| *branch != root)
96 .collect();
97
98 if branches.is_empty() {
99 println!("no stacked branches");
100 return Ok(());
101 }
102
103 let review_provider = detect_review_provider().ok().map(|(_, client)| client);
104 let entries: Vec<(String, Option<ReviewRequest>)> = branches
105 .iter()
106 .map(|branch| {
107 let review = review_provider
108 .as_ref()
109 .and_then(|rp| owned_review_for_branch(&**rp, branch).ok().flatten());
110 (branch.clone(), review)
111 })
112 .collect();
113
114 println!("{}", summary(&entries, &root, format));
115 println!();
116 for (index, (branch, review)) in entries.iter().enumerate() {
117 let number = index + 1;
118 match (format, review) {
119 (Format::Markdown, Some(review)) => {
120 println!(
121 "{number}. [{}]({}) - {}",
122 labeled_with_size(review, branch),
123 review.url,
124 review.state
125 );
126 }
127 (Format::Markdown, None) => println!("{number}. `{branch}` (no review)"),
128 (Format::Plain, Some(review)) => {
130 println!(
131 "{number}. {} - {}",
132 labeled_with_size(review, branch),
133 review.state
134 );
135 println!(" {}", review.url);
136 }
137 (Format::Plain, None) => println!("{number}. {branch} (no review)"),
138 }
139 }
140
141 Ok(())
142}
143
144fn labeled_with_size(review: &ReviewRequest, branch: &str) -> String {
148 let id = match branch_diff_size(branch) {
149 Some((added, deleted)) => format!("{}, +{added}/-{deleted}", review.id),
150 None => review.id.clone(),
151 };
152 label(&review.title, &id)
153}
154
155fn branch_diff_size(branch: &str) -> Option<(usize, usize)> {
158 let parent = stack::parent_of(branch).ok().flatten()?;
159 let (added, deleted) = git::diff_numstat(&parent, branch).ok()?;
160 (added > 0 || deleted > 0).then_some((added, deleted))
161}
162
163fn summary(entries: &[(String, Option<ReviewRequest>)], base: &str, format: Format) -> String {
166 let total = entries.len();
167 let reviews: Vec<&ReviewRequest> = entries.iter().filter_map(|(_, r)| r.as_ref()).collect();
168 let base = match format {
169 Format::Markdown => format!("`{base}`"),
170 Format::Plain => base.to_owned(),
171 };
172
173 let mut summary = if reviews.is_empty() {
174 format!(
175 "{total} branch{}, base {base}",
176 if total == 1 { "" } else { "es" }
177 )
178 } else if reviews.len() == total {
179 format!(
180 "{total} PR{}, base {base}",
181 if total == 1 { "" } else { "s" }
182 )
183 } else {
184 format!(
185 "{total} branches ({} with reviews), base {base}",
186 reviews.len()
187 )
188 };
189
190 if !reviews.is_empty() {
191 let mut counts = Vec::new();
192 for (state, label) in [
193 (ReviewState::Open, "open"),
194 (ReviewState::Merged, "merged"),
195 (ReviewState::Closed, "closed"),
196 ] {
197 let count = reviews
198 .iter()
199 .filter(|review| review.state == state)
200 .count();
201 if count > 0 {
202 counts.push(format!("{count} {label}"));
203 }
204 }
205 if !counts.is_empty() {
206 summary.push_str(&format!(", {}", counts.join(" / ")));
207 }
208 }
209
210 summary
211}