Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 77 additions & 6 deletions crates/voro/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1460,15 +1460,13 @@ impl App {
}

/// Drive the create-PR confirmation modal (DESIGN.md §8). Enter (or `y`)
/// runs the same `crate::pr::create` the CLI's `pr` calls, then refreshes;
/// esc (or `n`) cancels without touching anything.
/// runs the same `crate::pr::create` the CLI's `pr` calls and shows the new
/// PR, then refreshes; esc (or `n`) cancels without touching anything.
fn key_confirm_pr(&mut self, key: KeyEvent, task_id: i64, branch: String, title: String) {
match key.code {
KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
match crate::pr::create(&mut self.store, task_id) {
Ok(summary) => self.status = Some(summary),
Err(e) => self.status = Some(e),
}
let created = crate::pr::create(&mut self.store, task_id);
self.report_created_pr(task_id, created, crate::pr::open_url);
let result = self.refresh();
self.report(result);
}
Expand All @@ -1485,6 +1483,26 @@ impl App {
}
}

/// Report a create-PR attempt, chaining a success straight into the browser
/// (DESIGN.md §8): creating a PR is all but always followed by looking at
/// it, so `g` does both. The create is the durable half — its URL is already
/// recorded on the task — so a browser that will not launch is reported
/// beside the URL rather than as a failed create.
fn report_created_pr(
&mut self,
task_id: i64,
created: Result<String, String>,
open: impl FnOnce(&str) -> Result<String, String>,
) {
self.status = Some(match created {
Ok(url) => match open(&url) {
Ok(_) => format!("opened {url} for task {task_id} — showing it in the browser"),
Err(e) => format!("PR created ({url}); could not open browser: {e}"),
},
Err(e) => e,
});
}

/// Drive the link-a-PR prompt (DESIGN.md §11c). Enter validates and stores
/// the reference; esc cancels. The buffer is one line — a PR URL or the
/// `owner/repo#n` shorthand — so this stays a simple line editor.
Expand Down Expand Up @@ -3800,6 +3818,59 @@ mod tests {
assert!(app.store.task(task_id).unwrap().pr_url.is_none());
}

/// Confirming the create-PR modal shows the new PR without a second `g`:
/// the browser is launched with the URL `create` just recorded (DESIGN.md
/// §8). The launch is passed in so the chain is exercised without `gh`.
#[test]
fn a_created_pr_is_opened_in_the_browser() {
let mut app = app_with(&[TaskState::Review]);
let task_id = app.selected_task_id().unwrap();
let url = "https://github.com/acme/widget/pull/7";
let mut opened = None;
app.report_created_pr(task_id, Ok(url.to_string()), |u| {
opened = Some(u.to_string());
Ok(format!("opening {u} in the browser"))
});
assert_eq!(opened.as_deref(), Some(url));
let status = app.status.as_deref().unwrap_or("");
assert!(
status.contains(url) && status.contains("browser"),
"{status}"
);
}

/// A browser that will not launch does not turn a created PR into a
/// failure: the URL is recorded, so it is reported with the open error
/// alongside it (DESIGN.md §8).
#[test]
fn a_browser_failure_after_a_create_still_reports_the_pr() {
let mut app = app_with(&[TaskState::Review]);
let task_id = app.selected_task_id().unwrap();
let url = "https://github.com/acme/widget/pull/7";
app.report_created_pr(task_id, Ok(url.to_string()), |_| {
Err("cannot run `gh` to open the PR: not found".to_string())
});
let status = app.status.as_deref().unwrap_or("");
assert!(
status.contains("PR created") && status.contains(url) && status.contains("not found"),
"{status}"
);
}

/// A failed create is reported as-is and never reaches the browser.
#[test]
fn a_failed_create_does_not_open_a_browser() {
let mut app = app_with(&[TaskState::Review]);
let task_id = app.selected_task_id().unwrap();
let mut opened = false;
app.report_created_pr(task_id, Err("`gh pr create` failed".to_string()), |_| {
opened = true;
Ok(String::new())
});
assert!(!opened, "the browser was launched for a failed create");
assert_eq!(app.status.as_deref(), Some("`gh pr create` failed"));
}

/// Rejecting a review task with no tracked PR opens the ordinary feedback
/// prompt, empty — the pre-fill only fires when a PR is tracked (DESIGN.md
/// §11c), so this path never touches `gh`.
Expand Down
2 changes: 1 addition & 1 deletion crates/voro/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1408,7 +1408,7 @@ fn pr_verb(store: &mut Store, id: i64, yes: bool, ctx: &DispatchCtx) -> Result<S
if !yes && !confirm(&format!("push `{}` and open a PR for #{id}?", plan.branch))? {
return Ok(format!("cancelled — no PR opened for #{id}"));
}
crate::pr::create(store, id)
crate::pr::create(store, id).map(|url| format!("opened {url} for task {id}"))
}

/// Ask a yes/no question on the terminal, defaulting to no (DESIGN.md §8). A
Expand Down
20 changes: 14 additions & 6 deletions crates/voro/src/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@ fn tracked_pr(store: &Store, task_id: i64) -> Result<PrRef, String> {
}

/// Open a task's tracked PR in a browser via `gh pr view --web` (DESIGN.md
/// §11c). Spawned detached and reaped like the viewer in `dispatch::open`, so
/// nothing lingers as a zombie.
/// §11c).
pub fn open(store: &Store, task_id: i64) -> Result<String, String> {
let pr = tracked_pr(store, task_id)?;
open_url(&pr.url)
}

/// Open a PR URL in a browser, taking the URL rather than a task so a
/// just-created PR can be shown without re-reading the store (DESIGN.md §8).
/// Spawned detached and reaped like the viewer in `dispatch::open`, so nothing
/// lingers as a zombie.
pub fn open_url(url: &str) -> Result<String, String> {
let mut child = Command::new("gh")
.args(["pr", "view", "--web", &pr.url])
.args(["pr", "view", "--web", url])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
Expand All @@ -36,7 +43,7 @@ pub fn open(store: &Store, task_id: i64) -> Result<String, String> {
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(format!("opening {} in the browser", pr.url))
Ok(format!("opening {url} in the browser"))
}

/// Pull a task's tracked PR's review comments into a reject-with-feedback body
Expand Down Expand Up @@ -69,7 +76,8 @@ pub fn plan(store: &Store, task_id: i64) -> Result<PrPlan, String> {
/// state change — the task stays in `review` until a human accepts. The
/// operator has already been gated by the caller; `pr` is operator-invoked, so
/// the dispatched agent still cannot publish. Only called when no PR is
/// tracked; a tracked one jumps to [`open`] instead.
/// tracked; a tracked one jumps to [`open`] instead. Returns the canonical URL
/// so a caller can chain into [`open_url`] without re-reading the task.
pub fn create(store: &mut Store, task_id: i64) -> Result<String, String> {
let plan = plan(store, task_id)?;
let task = store.task(task_id).map_err(|e| e.to_string())?;
Expand All @@ -84,7 +92,7 @@ pub fn create(store: &mut Store, task_id: i64) -> Result<String, String> {
store
.set_pr(task_id, Some(&pr.url))
.map_err(|e| e.to_string())?;
Ok(format!("opened {} for task {task_id}", pr.url))
Ok(pr.url)
}

/// Ask GitHub whether a review task's tracked PR still merges cleanly with its
Expand Down
52 changes: 52 additions & 0 deletions crates/voro/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ fn draw_mode(frame: &mut Frame, app: &App) {
Span::raw("create a ready PR titled "),
Span::styled(format!("“{title}”"), Style::new().fg(Color::Blue)),
]),
Line::from(Span::raw("and open it in the browser")),
Line::default(),
Line::from(Span::styled(
"⏎/y confirm · esc/n cancel",
Expand Down Expand Up @@ -2731,4 +2732,55 @@ mod tests {
"header missing ready count: {header}"
);
}

/// The create-PR modal spells out every consequence of confirming, the
/// browser jump included (DESIGN.md §8), so the key's second half is not a
/// surprise.
#[test]
fn confirm_pr_modal_announces_the_browser_jump() {
use crate::app::{App, Mode};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use voro_core::{NewTask, Store};

let mut store = Store::open_in_memory().unwrap();
let p = store.create_project("voro", "/tmp/voro").unwrap();
let task = store
.create_task(NewTask {
project_id: p.id,
repo_id: None,
title: "ship it".into(),
body: String::new(),
priority: Priority::P2,
state: TaskState::Ready,
agent: None,
human: false,
deep: false,
})
.unwrap();

let ctx = crate::dispatch::DispatchCtx::from_db_path(std::path::Path::new(
"/nonexistent/voro.db",
));
let mut app = App::new(store, ctx).unwrap();
app.mode = Mode::ConfirmPr {
task_id: task.id,
branch: "feat/ship".into(),
title: "ship it".into(),
};

let mut terminal = Terminal::new(TestBackend::new(90, 24)).unwrap();
terminal.draw(|f| draw(f, &app)).unwrap();
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(
rendered.contains("open it in the browser"),
"modal should name the browser jump: {rendered}"
);
}
}
Loading
Loading