use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
use crate::domain::Task;
use crate::git;
use crate::store::Store;
pub const FORGE: &str = "gh";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
pub repo: String,
pub worktree: PathBuf,
pub branch: String,
pub base: String,
}
pub fn targets(store: &Store, task: &Task) -> crate::store::Result<Vec<Target>> {
let mut out = Vec::new();
for link in store.list_task_repos(task.id)? {
let (Some(worktree), Some(branch)) = (link.worktree_path, link.branch) else {
continue;
};
let repo = match store.get_repo(link.repo_id) {
Ok(repo) => repo.name,
Err(_) => continue,
};
out.push(Target {
repo,
worktree,
branch,
base: link.base_ref.unwrap_or_default(),
});
}
Ok(out)
}
#[derive(Debug, Clone)]
pub enum Job {
Push(Vec<Target>),
Open {
targets: Vec<Target>,
title: String,
body: String,
},
}
impl Job {
pub fn doing(&self) -> String {
let repos = match self {
Job::Push(targets) => targets.len(),
Job::Open { targets, .. } => targets.len(),
};
match self {
Job::Push(_) => format!("pushing {}", crate::tui::plural(repos, "branch")),
Job::Open { .. } => format!("opening {}", crate::tui::plural(repos, "pull request")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Update {
Says(String),
Opened { repo: String, url: String },
Done,
}
pub fn spawn(job: Job) -> Receiver<Update> {
let (tx, rx) = channel();
thread::spawn(move || run(job, &tx));
rx
}
pub fn run(job: Job, tx: &Sender<Update>) {
match job {
Job::Push(targets) => {
for target in &targets {
let _ = tx.send(match push(target) {
Ok(Pushed::Sent) => Update::Says(format!("pushed {}", target.repo)),
Ok(Pushed::NoRemote) => {
Update::Says(format!("{}: no remote to push to", target.repo))
}
Err(why) => Update::Says(format!("{}: {why}", target.repo)),
});
}
}
Job::Open {
targets,
title,
body,
} => {
if !available() {
let _ = tx.send(Update::Says(format!(
"{FORGE} is not installed, so there is nothing to open a pull request with"
)));
let _ = tx.send(Update::Done);
return;
}
for target in &targets {
match push(target) {
Ok(Pushed::Sent) => {}
Ok(Pushed::NoRemote) => {
let _ = tx.send(Update::Says(format!(
"{}: no remote, so no pull request",
target.repo
)));
continue;
}
Err(why) => {
let _ = tx.send(Update::Says(format!("{}: {why}", target.repo)));
continue;
}
}
let _ = tx.send(match open(target, &title, &body) {
Ok(Opened::Created(url)) => Update::Opened {
repo: target.repo.clone(),
url,
},
Ok(Opened::Already(url)) => Update::Opened {
repo: target.repo.clone(),
url,
},
Err(why) => Update::Says(format!("{}: {why}", target.repo)),
});
}
}
}
let _ = tx.send(Update::Done);
}
enum Pushed {
Sent,
NoRemote,
}
fn push(target: &Target) -> std::result::Result<Pushed, String> {
match git::has_remote(&target.worktree) {
Ok(true) => {}
Ok(false) => return Ok(Pushed::NoRemote),
Err(err) => return Err(err.to_string()),
}
git::push(&target.worktree, &target.branch)
.map(|()| Pushed::Sent)
.map_err(|err| trim(&err.to_string()))
}
enum Opened {
Created(String),
Already(String),
}
pub fn available() -> bool {
Command::new(FORGE)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn open(target: &Target, title: &str, body: &str) -> std::result::Result<Opened, String> {
if let Some(url) = existing(target) {
return Ok(Opened::Already(url));
}
let mut command = Command::new(FORGE);
command
.current_dir(&target.worktree)
.args(["pr", "create"])
.args(["--head", &target.branch])
.args(["--title", title])
.args(["--body", body]);
if !target.base.is_empty() {
command.args(["--base", &target.base]);
}
let output = command.output().map_err(|err| err.to_string())?;
if !output.status.success() {
return Err(trim(&String::from_utf8_lossy(&output.stderr)));
}
let url = String::from_utf8_lossy(&output.stdout)
.lines()
.rev()
.find(|line| line.starts_with("http"))
.unwrap_or_default()
.to_string();
Ok(Opened::Created(url))
}
fn existing(target: &Target) -> Option<String> {
let output = Command::new(FORGE)
.current_dir(&target.worktree)
.args([
"pr",
"view",
&target.branch,
"--json",
"url",
"--jq",
".url",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
url.starts_with("http").then_some(url)
}
fn trim(stderr: &str) -> String {
stderr
.lines()
.map(str::trim)
.rfind(|line| !line.is_empty())
.unwrap_or("failed")
.to_string()
}
pub const OPENED_EVENT: &str = "pr.opened";
pub fn record(store: &Store, task_id: i64, repo: &str, url: &str) {
let _ = store.append_event(
Some(task_id),
OPENED_EVENT,
&serde_json::json!({ "repo": repo, "url": url }),
chrono::Utc::now(),
);
}
pub fn description(task: &Task) -> (String, String) {
let title = task.title.trim();
let title = if title.is_empty() {
format!("marver task {}", task.id)
} else {
title.to_string()
};
let mut body = task.prompt.trim().to_string();
if body.is_empty() {
body.push_str("_No prompt was recorded._");
}
body.push_str(&format!("\n\n---\nmarver task {}\n", task.id));
(title, body)
}
pub fn is_live(target: &Target) -> bool {
Path::new(&target.worktree).exists()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::TaskState;
use crate::git::testing::init_repo;
use crate::store::Store;
use chrono::{DateTime, Utc};
use std::sync::mpsc::channel;
use tempfile::TempDir;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).expect("valid timestamp")
}
fn task_with(title: &str, prompt: &str) -> Task {
let mut store = Store::open_in_memory().unwrap();
store
.create_task(title, prompt, Path::new("/tmp/tasks"), &[], at(0))
.unwrap()
}
fn repo_with_origin(root: &Path, name: &str) -> (PathBuf, PathBuf) {
let repo = root.join(name);
init_repo(&repo, "main");
let origin = root.join(format!("{name}.git"));
std::process::Command::new("git")
.args(["init", "--bare", "--initial-branch=main"])
.arg(&origin)
.output()
.expect("git init --bare");
git::run(
&repo,
&["remote", "add", "origin", origin.to_str().unwrap()],
)
.unwrap();
(repo, origin)
}
#[test]
fn a_description_comes_from_what_the_task_was_asked_to_do() {
let task = task_with(
"Fix the auth flow",
"Sessions drop after an hour.\nFind out why.",
);
let (title, body) = description(&task);
assert_eq!(title, "Fix the auth flow");
assert!(body.starts_with("Sessions drop after an hour."), "{body}");
assert!(body.contains("Find out why."), "every line of it: {body}");
assert!(body.contains(&format!("marver task {}", task.id)), "{body}");
}
#[test]
fn a_task_with_no_title_still_gets_one() {
let task = task_with("", "do the thing");
let (title, _) = description(&task);
assert_eq!(title, format!("marver task {}", task.id));
}
#[test]
fn a_task_with_no_prompt_still_gets_a_body() {
let task = task_with("Something", "");
let (_, body) = description(&task);
assert!(!body.trim().is_empty());
assert!(body.contains("No prompt"), "{body}");
}
#[test]
fn a_branch_reaches_the_remote() {
let tmp = TempDir::new().unwrap();
let (repo, origin) = repo_with_origin(tmp.path(), "api");
git::run(&repo, &["checkout", "-q", "-b", "marver/1-thing"]).unwrap();
std::fs::write(repo.join("new.rs"), "fn f() {}\n").unwrap();
git::stage_all(&repo).unwrap();
git::commit(&repo, "agent work").unwrap();
let target = Target {
repo: "api".to_string(),
worktree: repo.clone(),
branch: "marver/1-thing".to_string(),
base: "main".to_string(),
};
let (tx, rx) = channel();
run(Job::Push(vec![target]), &tx);
drop(tx);
let said: Vec<Update> = rx.iter().collect();
assert_eq!(
said,
vec![Update::Says("pushed api".to_string()), Update::Done],
"{said:?}"
);
assert!(
git::ref_exists(&origin, "refs/heads/marver/1-thing").unwrap(),
"the branch must be on the remote"
);
}
#[test]
fn a_repo_with_no_remote_is_said_rather_than_failed() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("local");
init_repo(&repo, "main");
let (tx, rx) = channel();
run(
Job::Push(vec![Target {
repo: "local".to_string(),
worktree: repo,
branch: "main".to_string(),
base: "main".to_string(),
}]),
&tx,
);
drop(tx);
let said: Vec<Update> = rx.iter().collect();
assert_eq!(
said[0],
Update::Says("local: no remote to push to".to_string())
);
}
#[test]
fn one_repo_failing_does_not_stop_the_others() {
let tmp = TempDir::new().unwrap();
let (good, _) = repo_with_origin(tmp.path(), "api");
git::run(&good, &["checkout", "-q", "-b", "marver/1-x"]).unwrap();
let broken = tmp.path().join("broken");
std::fs::create_dir_all(&broken).unwrap();
let (tx, rx) = channel();
run(
Job::Push(vec![
Target {
repo: "broken".to_string(),
worktree: broken,
branch: "marver/1-x".to_string(),
base: "main".to_string(),
},
Target {
repo: "api".to_string(),
worktree: good,
branch: "marver/1-x".to_string(),
base: "main".to_string(),
},
]),
&tx,
);
drop(tx);
let said: Vec<Update> = rx.iter().collect();
assert_eq!(said.len(), 3, "one line each, then Done: {said:?}");
assert_eq!(said[2], Update::Done);
assert!(
matches!(&said[1], Update::Says(line) if line == "pushed api"),
"the second repo still went: {said:?}"
);
}
#[test]
fn a_push_that_is_rejected_says_what_the_remote_said() {
let tmp = TempDir::new().unwrap();
let (repo, origin) = repo_with_origin(tmp.path(), "api");
git::push(&repo, "main").unwrap();
let other = tmp.path().join("other");
std::process::Command::new("git")
.args(["clone", "-q", origin.to_str().unwrap()])
.arg(&other)
.output()
.unwrap();
git::run(&other, &["config", "user.email", "t@m"]).unwrap();
git::run(&other, &["config", "user.name", "t"]).unwrap();
std::fs::write(other.join("theirs.rs"), "fn g() {}\n").unwrap();
git::stage_all(&other).unwrap();
git::commit(&other, "someone else").unwrap();
git::push(&other, "main").unwrap();
std::fs::write(repo.join("ours.rs"), "fn f() {}\n").unwrap();
git::stage_all(&repo).unwrap();
git::commit(&repo, "ours").unwrap();
let (tx, rx) = channel();
run(
Job::Push(vec![Target {
repo: "api".to_string(),
worktree: repo,
branch: "main".to_string(),
base: "main".to_string(),
}]),
&tx,
);
drop(tx);
let said: Vec<Update> = rx.iter().collect();
let Update::Says(line) = &said[0] else {
panic!("expected a complaint: {said:?}");
};
assert!(line.starts_with("api: "), "named: {line}");
assert!(
!git::ref_exists(
std::path::Path::new(&origin),
"refs/heads/definitely-not-there"
)
.unwrap()
);
}
#[test]
fn opening_without_the_forge_installed_says_so_once() {
if available() {
return; }
let (tx, rx) = channel();
run(
Job::Open {
targets: vec![
Target {
repo: "a".into(),
worktree: PathBuf::from("/tmp"),
branch: "b".into(),
base: "main".into(),
},
Target {
repo: "b".into(),
worktree: PathBuf::from("/tmp"),
branch: "b".into(),
base: "main".into(),
},
],
title: "t".into(),
body: "b".into(),
},
&tx,
);
drop(tx);
let said: Vec<Update> = rx.iter().collect();
assert_eq!(said.len(), 2, "one complaint and Done: {said:?}");
let Update::Says(line) = &said[0] else {
panic!("{said:?}")
};
assert!(line.contains(FORGE), "{line}");
}
#[test]
fn targets_skip_a_repo_that_was_never_provisioned() {
let tmp = TempDir::new().unwrap();
let mut store = Store::open_in_memory().unwrap();
let path = tmp.path().join("api");
init_repo(&path, "main");
let repo = store.upsert_repo(&path, "api", at(0)).unwrap();
let task = store
.create_task("t", "p", tmp.path(), &[repo.id], at(0))
.unwrap();
assert!(
targets(&store, &task).unwrap().is_empty(),
"selected is not provisioned"
);
store
.record_worktree(task.id, repo.id, &path, "marver/1-t", "main")
.unwrap();
let found = targets(&store, &task).unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].repo, "api");
assert_eq!(found[0].branch, "marver/1-t");
assert_eq!(found[0].base, "main");
}
#[test]
fn a_pull_request_url_outlives_the_line_that_announced_it() {
let tmp = TempDir::new().unwrap();
let mut store = Store::open_in_memory().unwrap();
let task = store.create_task("t", "p", tmp.path(), &[], at(0)).unwrap();
record(&store, task.id, "api", "https://example.test/pr/1");
let events = store.list_events(task.id).unwrap();
let found = events
.iter()
.find(|e| e.kind == OPENED_EVENT)
.expect("recorded");
assert_eq!(found.payload["repo"], "api");
assert_eq!(found.payload["url"], "https://example.test/pr/1");
assert_eq!(TaskState::Queued, store.get_task(task.id).unwrap().state);
}
}