use diffler_core::vcs::{LogEntry, NetworkOp};
use super::App;
use crate::ci::NewPullRequest;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrField {
Base,
Title,
Body,
Draft,
Create,
Cancel,
}
impl PrField {
pub(crate) const ORDER: [Self; 6] = [
Self::Base,
Self::Title,
Self::Body,
Self::Draft,
Self::Create,
Self::Cancel,
];
pub(crate) fn step(self, down: bool) -> Self {
let at = Self::ORDER.iter().position(|f| *f == self).unwrap_or(0);
let next = if down {
(at + 1).min(Self::ORDER.len() - 1)
} else {
at.saturating_sub(1)
};
Self::ORDER.get(next).copied().unwrap_or(Self::Title)
}
pub(crate) fn as_text(self) -> Option<PrTextField> {
match self {
Self::Title => Some(PrTextField::Title),
Self::Body => Some(PrTextField::Body),
Self::Base | Self::Draft | Self::Create | Self::Cancel => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrTextField {
Title,
Body,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrDraft {
pub base: String,
pub head: String,
pub title: String,
pub body: String,
pub draft: bool,
pub commits: usize,
pub needs_push: bool,
pub field: PrField,
}
impl PrDraft {
pub(crate) fn request(&self) -> NewPullRequest {
NewPullRequest {
base: self.base.clone(),
head: self.head.clone(),
title: self.title.clone(),
body: self.body.clone(),
draft: self.draft,
}
}
}
fn defaults_from(commits: &[LogEntry], branch: &str) -> (String, String) {
if let [only] = commits {
(only.subject.clone(), String::new())
} else {
let title = humanize(branch);
let body = commits.iter().rev().fold(String::new(), |mut acc, c| {
use std::fmt::Write as _;
let _ = writeln!(acc, "- {}", c.subject);
acc
});
(title, body)
}
}
fn humanize(branch: &str) -> String {
let tail = branch.rsplit('/').next().unwrap_or(branch);
tail.replace(['-', '_'], " ")
}
impl App {
pub(crate) const PR_CREATE_PUSH: &'static str = "push for the pull request";
pub(crate) fn create_pr_start(&mut self) {
let Some(remote) = self.ci_remotes().first().cloned() else {
self.info("no forge detected for this repo");
return;
};
let Some(head) = self.head.branch.clone() else {
self.error("HEAD is detached; check out a branch to open a pull request");
return;
};
let base = match self.review.vcs.default_branch(&remote.name) {
Ok(Some(base)) => base,
Ok(None) => {
self.error(format!("no default branch on {}", remote.name));
return;
}
Err(err) => {
self.error(err.to_string());
return;
}
};
if base == head {
self.info(format!("already on {base}; branch first"));
return;
}
let base_rev = self.remote_rev(&remote.name, &base);
let commits = match self.review.vcs.commits_between(&base_rev, &head) {
Ok(commits) => commits,
Err(err) => {
self.error(err.to_string());
return;
}
};
if commits.is_empty() {
self.info(format!("{head} has nothing {base} doesn't"));
return;
}
let (title, body) = defaults_from(&commits, &head);
let needs_push = !self.branch_is_on_remote(&remote.name, &head);
self.modal = Some(super::Modal::CreatePr {
draft: Box::new(PrDraft {
base,
head,
title,
body,
draft: false,
commits: commits.len(),
needs_push,
field: PrField::Title,
}),
});
}
fn remote_rev(&self, remote: &str, branch: &str) -> String {
let tracking = format!("refs/remotes/{remote}/{branch}");
if self.review.vcs.resolve(&tracking).is_ok() {
tracking
} else {
branch.to_owned()
}
}
fn branch_is_on_remote(&self, remote: &str, branch: &str) -> bool {
let there = self
.review
.vcs
.resolve(&format!("refs/remotes/{remote}/{branch}"));
match (there, self.review.vcs.resolve(branch)) {
(Ok(there), Ok(here)) => there == here,
_ => false,
}
}
pub(crate) fn create_pr_submit(&mut self) {
let Some(super::Modal::CreatePr { draft }) = self.modal.take() else {
return;
};
if draft.title.trim().is_empty() {
self.info("a pull request needs a title");
self.modal = Some(super::Modal::CreatePr { draft });
return;
}
let request = draft.request();
if !draft.needs_push {
self.queue_pr_create(request);
return;
}
let Some(remote) = self.ci_remotes().first().map(|r| r.name.clone()) else {
self.error("no remote to push the branch to");
return;
};
if !self.review.vcs.has_index() {
self.error("push the bookmark with P p, then open the pull request");
return;
}
self.pending_pr_create = Some(Box::new(request));
self.queue_network_op(Self::PR_CREATE_PUSH, NetworkOp::PushSetUpstream { remote });
}
pub(crate) fn queue_pr_create(&mut self, request: NewPullRequest) {
self.info(format!("opening {} → {}…", request.head, request.base));
self.pending_ci = Some(super::CiRequest::CreatePr(Box::new(request)));
}
pub(crate) fn pr_create_after_push(&mut self) {
if let Some(request) = self.pending_pr_create.take() {
self.queue_pr_create(*request);
}
}
pub(crate) fn on_pr_created(&mut self, result: Result<crate::ci::PullRequest, String>) {
match result {
Ok(pr) => {
self.info(format!("opened #{}: {}", pr.number, pr.title));
self.seat_branch_pr(pr.clone());
if pr.head_oid.is_empty() {
return;
}
self.open_pr_review_for(pr);
}
Err(err) => self.error(err),
}
}
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyCode;
use super::*;
fn entry(subject: &str) -> LogEntry {
LogEntry {
oid: "0".repeat(40),
oid7: "0000000".to_owned(),
refs: Vec::new(),
parents: Vec::new(),
subject: subject.to_owned(),
author: "reviewer".to_owned(),
time_unix: 0,
change_id: None,
}
}
#[test]
fn a_lone_commit_titles_the_pull_request() {
let (title, body) = defaults_from(&[entry("fix: stop dropping the reply")], "fix/reply");
assert_eq!(title, "fix: stop dropping the reply");
assert!(body.is_empty(), "one commit needs no summary of itself");
}
#[test]
fn a_series_is_titled_by_its_branch_and_listed_oldest_first() {
let commits = [entry("second"), entry("first")];
let (title, body) = defaults_from(&commits, "feat/pr-create");
assert_eq!(title, "pr create");
assert_eq!(body, "- first\n- second\n");
}
#[test]
fn fields_stop_at_both_ends() {
assert_eq!(PrField::Base.step(false), PrField::Base);
assert_eq!(PrField::Base.step(true), PrField::Title);
assert_eq!(PrField::Draft.step(true), PrField::Create);
assert_eq!(PrField::Create.step(true), PrField::Cancel);
assert_eq!(PrField::Cancel.step(true), PrField::Cancel);
assert_eq!(PrField::Create.step(false), PrField::Draft);
}
#[test]
fn a_branch_with_no_tracking_ref_still_needs_a_push() {
let fixture = crate::test_support::standard_fixture();
fixture.commit_all("base");
let app = App::new(fixture.review(), crate::config::LoadedConfig::default());
assert!(
!app.branch_is_on_remote("origin", "main"),
"no remote-tracking ref means the branch still has to be pushed"
);
}
#[test]
fn composing_without_a_forge_is_refused() {
let fixture = crate::test_support::standard_fixture();
fixture.commit_all("base");
let mut app = App::new(fixture.review(), crate::config::LoadedConfig::default());
app.create_pr_start();
assert!(app.modal.is_none(), "no forge, no form");
}
#[test]
fn an_unpushed_branch_pushes_before_the_forge_call() {
let fixture = crate::test_support::standard_fixture();
fixture.remote("origin", "https://github.com/acme/widgets.git");
let mut app = App::new(fixture.review(), crate::config::LoadedConfig::default());
app.modal = Some(super::super::Modal::CreatePr {
draft: Box::new(PrDraft {
base: "main".to_owned(),
head: "feat/x".to_owned(),
title: "a title".to_owned(),
body: String::new(),
draft: false,
commits: 1,
needs_push: true,
field: PrField::Title,
}),
});
app.create_pr_submit();
assert!(app.pending_pr_create.is_some(), "the request waits");
assert!(
!matches!(app.pending_ci, Some(super::super::CiRequest::CreatePr(_))),
"nothing reaches the forge before the branch does"
);
assert_eq!(
app.pending_git.as_ref().map(|op| op.label.as_str()),
Some(App::PR_CREATE_PUSH),
);
}
#[test]
fn the_forge_call_waits_for_the_refresh_the_finished_push_queues() {
let fixture = crate::test_support::standard_fixture();
let mut app = App::new(fixture.review(), crate::config::LoadedConfig::default());
app.pending_pr_create = Some(Box::new(NewPullRequest {
base: "main".to_owned(),
head: "feat/x".to_owned(),
title: "a title".to_owned(),
body: String::new(),
draft: false,
}));
app.handle(crate::event::AppEvent::GitDone {
label: App::PR_CREATE_PUSH.to_owned(),
ok: true,
output: String::new(),
});
assert!(app.pending_ci.is_none(), "the create holds for the refresh");
app.settle_refresh();
assert!(matches!(
app.pending_ci,
Some(super::super::CiRequest::CreatePr(_))
));
}
#[test]
fn a_title_left_empty_keeps_the_form_open() {
let fixture = crate::test_support::standard_fixture();
let mut app = App::new(fixture.review(), crate::config::LoadedConfig::default());
app.modal = Some(super::super::Modal::CreatePr {
draft: Box::new(PrDraft {
base: "main".to_owned(),
head: "feat/x".to_owned(),
title: " ".to_owned(),
body: String::new(),
draft: false,
commits: 1,
needs_push: false,
field: PrField::Title,
}),
});
app.create_pr_submit();
assert!(
matches!(app.modal, Some(super::super::Modal::CreatePr { .. })),
"the form stays up so the title can be filled in"
);
assert!(app.pending_ci.is_none());
}
fn on_base(head: &str) -> PrDraft {
PrDraft {
base: "main".to_owned(),
head: head.to_owned(),
title: "a title".to_owned(),
body: String::new(),
draft: false,
commits: 1,
needs_push: false,
field: PrField::Base,
}
}
#[test]
fn the_base_field_picks_from_the_remotes_branches() {
let fixture = crate::test_support::standard_fixture();
fixture.remote("origin", "https://github.com/acme/widgets.git");
fixture.track("main", "HEAD");
fixture.branch("feat/x");
fixture.track("feat/x", "HEAD");
let mut app = App::new(fixture.review(), crate::config::LoadedConfig::default());
app.open_pr_base_list(Box::new(on_base("feat/x")));
let Some(super::super::Modal::PrBase { names, .. }) = &app.modal else {
panic!(
"the base field opens a picker, not a text field: {:?}",
app.modal
);
};
assert_eq!(names.first().map(String::as_str), Some("main"), "{names:?}");
assert!(
!names.iter().any(|name| name == "feat/x"),
"a branch cannot merge into itself: {names:?}"
);
}
#[test]
fn picking_a_base_returns_to_the_form_with_it() {
let fixture = crate::test_support::standard_fixture();
fixture.remote("origin", "https://github.com/acme/widgets.git");
fixture.track("main", "HEAD");
fixture.branch("release");
fixture.track("release", "HEAD");
let mut app = App::new(fixture.review(), crate::config::LoadedConfig::default());
app.open_pr_base_list(Box::new(on_base("feat/x")));
let Some(super::super::Modal::PrBase { names, list, .. }) = &mut app.modal else {
panic!("picker open");
};
let at = names
.iter()
.position(|name| name == "release")
.expect("the other branch is offered");
list.selected = list
.matches
.iter()
.position(|index| *index == at)
.expect("it is in the ranked matches");
app.handle_modal_key(&key(KeyCode::Enter));
let Some(super::super::Modal::CreatePr { draft }) = &app.modal else {
panic!("the form comes back: {:?}", app.modal);
};
assert_eq!(draft.base, "release");
}
#[test]
fn leaving_the_picker_keeps_the_form_and_its_base() {
let fixture = crate::test_support::standard_fixture();
fixture.remote("origin", "https://github.com/acme/widgets.git");
fixture.track("main", "HEAD");
let mut app = App::new(fixture.review(), crate::config::LoadedConfig::default());
app.open_pr_base_list(Box::new(on_base("feat/x")));
app.handle_modal_key(&key(KeyCode::Esc));
let Some(super::super::Modal::CreatePr { draft }) = &app.modal else {
panic!("the form comes back: {:?}", app.modal);
};
assert_eq!(draft.base, "main", "an abandoned pick changes nothing");
}
fn key(code: KeyCode) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
fn click(row: u16) -> crossterm::event::MouseEvent {
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 4,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
#[test]
fn a_click_lands_on_the_row_under_the_pointer() {
let fixture = crate::test_support::standard_fixture();
fixture.remote("origin", "https://github.com/acme/widgets.git");
fixture.track("main", "HEAD");
fixture.branch("release");
fixture.track("release", "HEAD");
let mut app = App::new(fixture.review(), crate::config::LoadedConfig::default());
app.open_pr_base_list(Box::new(on_base("feat/x")));
app.modal_hits = Some(crate::ui::popup::ListHits {
first_row: 10,
rows: 4,
first_index: 0,
});
app.handle_modal_mouse(click(11));
let Some(super::super::Modal::PrBase { list, .. }) = &app.modal else {
panic!("picker open");
};
assert_eq!(list.selected, 1, "the second row is under row 11");
app.handle_modal_mouse(click(11));
assert!(
matches!(app.modal, Some(super::super::Modal::CreatePr { .. })),
"a second click on the selected row activates: {:?}",
app.modal
);
}
#[test]
fn the_wheel_walks_the_create_form() {
let fixture = crate::test_support::standard_fixture();
let mut app = App::new(fixture.review(), crate::config::LoadedConfig::default());
app.modal = Some(super::super::Modal::CreatePr {
draft: Box::new(on_base("feat/x")),
});
app.handle_modal_mouse(crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 4,
row: 12,
modifiers: crossterm::event::KeyModifiers::NONE,
});
let Some(super::super::Modal::CreatePr { draft }) = &app.modal else {
panic!("form open");
};
assert_eq!(draft.field, PrField::Title, "the wheel steps the field");
}
}
#[cfg(test)]
mod form_tests {
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use super::PrField;
use crate::app::{App, Modal};
use crate::config::LoadedConfig;
use crate::test_support::standard_fixture;
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
fn click(row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 4,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
fn form(field: PrField) -> (crate::test_support::Fixture, App) {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.modal = Some(Modal::CreatePr {
draft: Box::new(super::PrDraft {
base: "main".to_owned(),
head: "feat/x".to_owned(),
title: "a title".to_owned(),
body: "a body".to_owned(),
draft: false,
commits: 1,
needs_push: false,
field,
}),
});
(fixture, app)
}
fn open_field(app: &App) -> Option<PrField> {
match app.modal.as_ref()? {
Modal::CreatePr { draft } => Some(draft.field),
_ => None,
}
}
#[test]
fn the_body_opens_the_inline_editor() {
let (_fixture, mut app) = form(PrField::Body);
app.handle_modal_key(&press(KeyCode::Enter));
assert!(
matches!(app.modal, Some(Modal::Input { ref title, .. }) if title == "Body"),
"{:?}",
app.modal
);
assert!(app.pending_editor.is_none(), "no editor was launched");
}
#[test]
fn an_emptied_body_sticks_but_an_emptied_title_does_not() {
let (_fixture, mut app) = form(PrField::Body);
app.handle_modal_key(&press(KeyCode::Enter));
if let Some(Modal::Input { buffer, cursor, .. }) = app.modal.as_mut() {
buffer.clear();
*cursor = 0;
}
app.handle_modal_key(&press(KeyCode::Enter));
let Some(Modal::CreatePr { draft }) = app.modal.as_ref() else {
panic!("back on the form: {:?}", app.modal);
};
assert_eq!(draft.body, "");
assert_eq!(draft.title, "a title", "the title is untouched");
}
#[test]
fn e_hands_the_body_to_the_editor() {
let (_fixture, mut app) = form(PrField::Body);
app.handle_modal_key(&press(KeyCode::Char('e')));
assert!(app.pending_editor.is_some(), "an editor was queued");
}
#[test]
fn e_writes_a_scratch_file_removed_after_the_round_trip() {
let (_fixture, mut app) = form(PrField::Body);
app.handle_modal_key(&press(KeyCode::Char('e')));
let request = app.pending_editor.take().expect("editor queued");
let crate::editor::EditorPurpose::PrBody { msg_path, .. } = &request.purpose else {
panic!("expected a PrBody purpose, got {:?}", request.purpose);
};
assert!(
msg_path.starts_with(std::env::temp_dir()),
"{}",
msg_path.display()
);
assert_eq!(std::fs::read_to_string(msg_path).unwrap(), "a body");
let path = msg_path.clone();
app.editor_finished(request.purpose, Ok(true));
assert!(!path.exists(), "the scratch file is removed");
}
#[test]
fn e_on_the_title_lands_in_the_title_not_the_body() {
let (_fixture, mut app) = form(PrField::Title);
app.handle_modal_key(&press(KeyCode::Char('e')));
let request = app.pending_editor.take().expect("editor queued");
let crate::editor::EditorPurpose::PrBody { msg_path, .. } = &request.purpose else {
panic!("expected a PrBody purpose, got {:?}", request.purpose);
};
std::fs::write(msg_path, "a new title").unwrap();
app.editor_finished(request.purpose, Ok(true));
let Some(Modal::CreatePr { draft }) = app.modal.as_ref() else {
panic!("back on the form: {:?}", app.modal);
};
assert_eq!(draft.title, "a new title");
assert_eq!(draft.body, "a body", "the body is untouched");
}
#[test]
fn a_cancelled_body_edit_keeps_the_body() {
let (_fixture, mut app) = form(PrField::Body);
app.handle_modal_key(&press(KeyCode::Char('e')));
let request = app.pending_editor.take().expect("editor queued");
let crate::editor::EditorPurpose::PrBody { msg_path, .. } = &request.purpose else {
panic!("expected a PrBody purpose, got {:?}", request.purpose);
};
std::fs::write(msg_path, "an edit the editor never saved").unwrap();
let path = msg_path.clone();
app.editor_finished(request.purpose, Ok(false));
assert!(!path.exists(), "the scratch file is removed regardless");
let Some(Modal::CreatePr { draft }) = app.modal.as_ref() else {
panic!("back on the form: {:?}", app.modal);
};
assert_eq!(draft.body, "a body", "the cancelled edit changed nothing");
}
#[test]
fn e_on_a_non_text_field_says_so() {
let (_fixture, mut app) = form(PrField::Draft);
app.handle_modal_key(&press(KeyCode::Char('e')));
assert!(app.pending_editor.is_none());
assert_eq!(open_field(&app), Some(PrField::Draft), "the form stays up");
}
#[test]
fn the_cancel_button_closes_the_form() {
let (_fixture, mut app) = form(PrField::Cancel);
app.handle_modal_key(&press(KeyCode::Enter));
assert!(app.modal.is_none());
}
#[test]
fn the_create_button_submits() {
let (_fixture, mut app) = form(PrField::Create);
app.handle_modal_key(&press(KeyCode::Enter));
assert!(app.modal.is_none() || app.pending_git.is_some());
}
#[test]
fn clicking_the_cancel_button_closes_the_form() {
let (_fixture, mut app) = form(PrField::Base);
app.modal_hits = Some(crate::ui::popup::ListHits {
first_row: 3,
rows: PrField::ORDER.len() as u16,
first_index: 0,
});
let cancel_row = 3 + PrField::ORDER.len() as u16 - 1;
app.handle_modal_mouse(click(cancel_row));
assert_eq!(
open_field(&app),
Some(PrField::Cancel),
"first click selects"
);
app.handle_modal_mouse(click(cancel_row));
assert!(app.modal.is_none(), "second click takes it");
}
}