Skip to main content

bb_cli/commands/
pr_edit.rs

1//! `bb pr edit`: change an open pull request's title or description in place.
2//!
3//! This rides the same `PUT` as `pr retarget` and `pr reviewers`. The title is
4//! always sent, because the api rejects a `PUT` without one. The description is
5//! sent only when it changed, so fixing a typo in the title never rewrites text
6//! nobody touched.
7
8use 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/// The new text a caller asked for. `None` leaves that field alone.
23#[derive(Debug, Default, PartialEq, Eq)]
24struct Requested {
25    title: Option<String>,
26    description: Option<String>,
27}
28
29/// Only the fields that differ from what the pull request says now.
30#[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
49/// Drops every requested value that matches the current one. Trailing
50/// whitespace is ignored for the description: an editor or `echo` adds a
51/// final newline, and that alone is not an edit anyone meant.
52fn 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
69/// What the flags ask for, or `None` when no content flag was given and the
70/// caller should be prompted instead.
71fn 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
88/// Left uncovered on purpose: it needs a terminal, and the decision it feeds
89/// is made by `plan`, which is tested.
90fn 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    // A closed pull request answers the `PUT` with an unhelpful 400, so the
140    // state is checked here where the message can name what is wrong.
141    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(&current_title, &current_description)?,
153    };
154    let plan = plan(&current_title, &current_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(&current_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}