bb_cli/commands/
pr_edit.rs1use crate::api::models::PullRequest;
9use crate::commands::pr::Ctx;
10use crate::error::{BbError, Result};
11use crate::output::{self, Format};
12use serde::Serialize;
13
14#[derive(Debug, Default)]
15pub struct EditArgs {
16 pub id: u64,
17 pub title: Option<String>,
18 pub description: Option<String>,
19 pub description_stdin: bool,
20}
21
22#[derive(Debug, Default, PartialEq, Eq)]
24struct Requested {
25 title: Option<String>,
26 description: Option<String>,
27}
28
29#[derive(Debug, Default, PartialEq, Eq)]
31struct Plan {
32 title: Option<String>,
33 description: Option<String>,
34}
35
36impl Plan {
37 fn changed(&self) -> Vec<&'static str> {
38 let mut changed = Vec::new();
39 if self.title.is_some() {
40 changed.push("title");
41 }
42 if self.description.is_some() {
43 changed.push("description");
44 }
45 changed
46 }
47}
48
49fn plan(current_title: &str, current_description: &str, requested: Requested) -> Plan {
53 Plan {
54 title: requested.title.filter(|t| t != current_title),
55 description: requested
56 .description
57 .filter(|d| d.trim_end() != current_description.trim_end()),
58 }
59}
60
61fn clean_title(raw: &str) -> Result<String> {
62 let title = raw.trim();
63 if title.is_empty() {
64 return Err(BbError::Config("the title cannot be empty".into()));
65 }
66 Ok(title.to_string())
67}
68
69fn from_flags(args: &EditArgs) -> Result<Option<Requested>> {
72 if args.title.is_none() && args.description.is_none() && !args.description_stdin {
73 return Ok(None);
74 }
75 let title = args.title.as_deref().map(clean_title).transpose()?;
76 let description = if args.description_stdin {
77 let mut buf = String::new();
78 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
79 Some(buf.trim_end_matches('\n').to_string())
80 } else {
81 args.description
82 .as_deref()
83 .map(|d| d.trim_end_matches('\n').to_string())
84 };
85 Ok(Some(Requested { title, description }))
86}
87
88fn prompt(current_title: &str, current_description: &str) -> Result<Requested> {
91 let title = inquire::Text::new("title:")
92 .with_initial_value(current_title)
93 .prompt()
94 .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
95 let description = inquire::Editor::new("description:")
96 .with_predefined_text(current_description)
97 .with_file_extension(".md")
98 .prompt()
99 .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
100 Ok(Requested {
101 title: Some(clean_title(&title)?),
102 description: Some(description.trim_end_matches('\n').to_string()),
103 })
104}
105
106#[derive(Serialize)]
107struct EditRow {
108 id: u64,
109 title: String,
110 description: String,
111 url: String,
112 changed: Vec<&'static str>,
113}
114
115impl EditRow {
116 fn from(pr: &PullRequest, changed: Vec<&'static str>) -> Self {
117 Self {
118 id: pr.id,
119 title: pr.title.clone().unwrap_or_default(),
120 description: pr.description_text().to_string(),
121 url: pr.html_url().to_string(),
122 changed,
123 }
124 }
125}
126
127pub async fn run(ctx: &Ctx, args: EditArgs) -> Result<()> {
128 let id = args.id;
129 let flags = from_flags(&args)?;
130 if flags.is_none() && !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
131 return Err(BbError::Config(format!(
132 "nothing to change on #{id} — pass --title, --description or --description-stdin"
133 )));
134 }
135
136 let path = ctx.path(&format!("/pullrequests/{id}"));
137 let pr: PullRequest = ctx.client.get_json(&path).await?;
138
139 let state = pr.state.as_deref().unwrap_or("UNKNOWN");
142 if !state.eq_ignore_ascii_case("OPEN") {
143 return Err(BbError::Config(format!(
144 "pull request #{id} is {state}, and only an open one can be edited"
145 )));
146 }
147
148 let current_title = pr.title.clone().unwrap_or_default();
149 let current_description = pr.description_text().to_string();
150 let requested = match flags {
151 Some(requested) => requested,
152 None => prompt(¤t_title, ¤t_description)?,
153 };
154 let plan = plan(¤t_title, ¤t_description, requested);
155 let changed = plan.changed();
156
157 if changed.is_empty() {
158 match ctx.format {
159 Format::Json => output::print_json(&EditRow::from(&pr, changed))?,
160 Format::Human => output::info(&format!(
161 "pull request #{id} already says that — nothing to do"
162 )),
163 }
164 return Ok(());
165 }
166
167 let mut body = serde_json::json!({
168 "title": plan.title.as_deref().unwrap_or(¤t_title),
169 });
170 if let Some(description) = &plan.description {
171 body["description"] = serde_json::Value::String(description.clone());
172 }
173 let updated: PullRequest = ctx.client.put_json(&path, &body).await?;
174
175 let row = EditRow::from(&updated, changed);
176 match ctx.format {
177 Format::Json => output::print_json(&row)?,
178 Format::Human => {
179 output::success(&format!(
180 "pull request #{id} updated: {}",
181 row.changed.join(", ")
182 ));
183 output::info(&row.url);
184 }
185 }
186 Ok(())
187}
188
189#[cfg(test)]
190#[allow(clippy::unwrap_used)]
191mod tests {
192 use super::*;
193
194 fn requested(title: Option<&str>, description: Option<&str>) -> Requested {
195 Requested {
196 title: title.map(str::to_string),
197 description: description.map(str::to_string),
198 }
199 }
200
201 #[test]
202 fn a_trailing_newline_alone_is_not_a_description_change() {
203 let p = plan("T", "body", requested(None, Some("body\n")));
204 assert!(p.changed().is_empty(), "got {p:?}");
205 }
206
207 #[test]
208 fn clearing_the_description_is_a_change() {
209 let p = plan("T", "body", requested(None, Some("")));
210 assert_eq!(p.description.as_deref(), Some(""));
211 }
212
213 #[test]
214 fn an_identical_title_is_dropped_and_a_new_one_kept() {
215 assert!(plan("T", "", requested(Some("T"), None)).title.is_none());
216 assert_eq!(
217 plan("T", "", requested(Some("U"), None)).title.as_deref(),
218 Some("U")
219 );
220 }
221
222 #[test]
223 fn changed_lists_title_before_description() {
224 let p = plan("T", "a", requested(Some("U"), Some("b")));
225 assert_eq!(p.changed(), vec!["title", "description"]);
226 }
227
228 #[test]
229 fn a_blank_title_is_rejected() {
230 assert!(clean_title(" ").is_err());
231 assert_eq!(clean_title(" Fix it ").unwrap(), "Fix it");
232 }
233}