use std::ffi::OsString;
use std::io::{BufRead, BufReader, Read};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use crate::base_fix::BaseFixAsk;
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::logs;
use crate::progress;
use crate::run_ending;
const POLL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Kind {
Ticket { spec_branch: String },
BaseFix { base: String },
}
impl Kind {
pub fn base(&self) -> &str {
match self {
Kind::Ticket { spec_branch } => spec_branch,
Kind::BaseFix { base } => base,
}
}
fn hidden_argument(&self) -> &'static str {
match self {
Kind::Ticket { .. } => SPEC_BRANCH,
Kind::BaseFix { .. } => BASE_FIX_INTO,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Given {
pub kind: Kind,
pub stamp: String,
pub base_fix: BaseFixAsk,
}
const SPEC_BRANCH: &str = "--spec-branch";
const BASE_FIX_INTO: &str = "--base-fix-into";
const STAMP: &str = "--stamp";
const ALLOW_BASE_FIX: &str = "--allow-base-fix";
const OFFER_BASE_FIX: &str = "--offer-base-fix";
impl Given {
fn to_args(&self) -> Vec<&str> {
let mut args = vec![
self.kind.hidden_argument(),
self.kind.base(),
STAMP,
&self.stamp,
];
match &self.base_fix {
BaseFixAsk::Allow => args.push(ALLOW_BASE_FIX),
BaseFixAsk::Forbid => {}
BaseFixAsk::Undecided { retry } => args.extend([OFFER_BASE_FIX, retry]),
}
args
}
}
#[derive(Default)]
pub struct Reader {
kind: Option<Kind>,
stamp: Option<String>,
base_fix: Option<BaseFixAsk>,
}
impl Reader {
pub fn take<'a>(
&mut self,
arg: &str,
rest: &mut impl Iterator<Item = &'a String>,
) -> Result<bool> {
let mut value = |missing: &str| match rest.next() {
Some(value) => Ok(value.clone()),
None => bail!("missing {missing}"),
};
match arg {
SPEC_BRANCH | BASE_FIX_INTO => {
not_yet_given(&self.kind, arg)?;
let base = value("Base branch")?;
self.kind = Some(if arg == SPEC_BRANCH {
Kind::Ticket { spec_branch: base }
} else {
Kind::BaseFix { base }
});
}
STAMP => {
not_yet_given(&self.stamp, arg)?;
self.stamp = Some(value("stamp")?);
}
ALLOW_BASE_FIX | OFFER_BASE_FIX => {
if let Some(given) = &self.base_fix
&& matches!(given, BaseFixAsk::Allow) != (arg == ALLOW_BASE_FIX)
{
bail!("{ALLOW_BASE_FIX} and {OFFER_BASE_FIX} can't be used together");
}
not_yet_given(&self.base_fix, arg)?;
self.base_fix = Some(if arg == ALLOW_BASE_FIX {
BaseFixAsk::Allow
} else {
BaseFixAsk::Undecided {
retry: value("command to offer")?,
}
});
}
_ => return Ok(false),
}
Ok(true)
}
pub fn finish(self) -> Result<Option<Given>> {
let Some(kind) = self.kind else {
if self.stamp.is_some() || self.base_fix.is_some() {
bail!("only a child Run is given {STAMP}, {ALLOW_BASE_FIX} or {OFFER_BASE_FIX}");
}
return Ok(None);
};
let stamp = self.stamp.with_context(|| format!("missing {STAMP}"))?;
Ok(Some(Given {
kind,
stamp,
base_fix: self.base_fix.unwrap_or(BaseFixAsk::Forbid),
}))
}
}
fn not_yet_given<T>(given: &Option<T>, arg: &str) -> Result<()> {
if given.is_some() {
bail!("repeated argument: {arg}");
}
Ok(())
}
pub enum Ended {
Reached {
pr_url: Option<String>,
base_fix: Option<String>,
},
Failed { cause: String, log: Option<String> },
Interrupted,
}
pub struct Handle {
number: u64,
child: Child,
}
pub fn start(issue: &IssueUrl, kind: Kind, base_fix: BaseFixAsk) -> Result<Handle> {
let given = Given {
kind,
stamp: logs::stamp().to_string(),
base_fix,
};
start_from(&own_executable()?, issue, &given)
}
fn own_executable() -> Result<PathBuf> {
if cfg!(target_os = "linux") {
Ok(PathBuf::from("/proc/self/exe"))
} else {
std::env::current_exe().context("no thirdshift executable")
}
}
fn start_from(executable: &Path, issue: &IssueUrl, given: &Given) -> Result<Handle> {
let mut command = Command::new(executable);
if cfg!(target_os = "linux")
&& let Some(own) = own_command()
{
command.arg0(own);
}
let child = command
.args(given.to_args())
.arg(&issue.url)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| {
let leads_to = match std::fs::read_link(executable) {
Ok(target) => format!(" ({})", target.display()),
Err(_) => String::new(),
};
format!(
"could not start the Run for #{} from {}{leads_to}",
issue.number,
executable.display()
)
})?;
Ok(Handle {
number: issue.number,
child,
})
}
fn own_command() -> Option<OsString> {
std::env::args_os().next()
}
pub fn name_this_process() {
#[cfg(target_os = "linux")]
if let Some(own) = own_command()
&& let Some(name) = Path::new(&own).file_name()
&& let Ok(name) = std::ffi::CString::new(name.as_encoded_bytes())
{
unsafe { libc::prctl(libc::PR_SET_NAME, name.as_ptr()) };
}
}
impl Handle {
pub fn wait(self) -> Result<Ended> {
let Handle { number, mut child } = self;
let stderr = child.stderr.take().context("no stderr from the Run")?;
let relay = thread::spawn(move || -> std::io::Result<_> {
let mut reader = run_ending::Reader::default();
for line in BufReader::new(stderr).lines() {
reader.read(progress::relay(number, &line?));
}
Ok(reader)
});
let mut passed_on = false;
let status = loop {
if !passed_on && interrupt::requested() {
unsafe { libc::kill(child.id() as libc::pid_t, libc::SIGTERM) };
passed_on = true;
}
if let Some(status) = child
.try_wait()
.with_context(|| format!("could not wait for the Run for #{number}"))?
{
break status;
}
thread::sleep(POLL);
};
let reader = relay
.join()
.map_err(|_| anyhow!("the relay of the Run for #{number} panicked"))?
.with_context(|| format!("could not read the Run for #{number}"))?;
let mut stdout = String::new();
child
.stdout
.take()
.context("no stdout from the Run")?
.read_to_string(&mut stdout)
.with_context(|| format!("could not read the Run for #{number}"))?;
let ending = reader.finish(&stdout, status.success());
Ok(match ending.outcome {
Ok(pr_url) => Ended::Reached {
pr_url,
base_fix: ending.base_fix,
},
Err(_) if interrupt::requested() => Ended::Interrupted,
Err(failure) => Ended::Failed {
cause: failure.cause.unwrap_or_else(|| format!("the Run {status}")),
log: failure.log,
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cause_of_not_starting_from(executable: &Path) -> String {
let issue = IssueUrl::parse("https://github.com/acme/widgets/issues/248").unwrap();
let given = Given {
kind: ticket(),
stamp: STAMPED.to_string(),
base_fix: BaseFixAsk::Forbid,
};
let Err(error) = start_from(executable, &issue, &given) else {
panic!("started from {}", executable.display());
};
format!("{error:#}")
}
const URL: &str = "https://github.com/acme/widgets/issues/248";
const STAMPED: &str = "20261003T120000-0400";
fn ticket() -> Kind {
Kind::Ticket {
spec_branch: "issue-237".to_string(),
}
}
fn base_fix() -> Kind {
Kind::BaseFix {
base: "main".to_string(),
}
}
fn read(args: &[&str]) -> Result<Option<Given>> {
let args: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
match crate::args::parse(&args)? {
crate::args::Command::Run(run) => Ok(run.given),
_ => panic!("{args:?}: not a Run"),
}
}
#[test]
fn what_a_child_run_is_given_reads_back_as_written_around_its_issue_url_and_a_flag() {
let offer = BaseFixAsk::Undecided {
retry: format!("thirdshift {URL} --no-merge base-fix"),
};
for kind in [ticket(), base_fix()] {
for base_fix in [BaseFixAsk::Allow, BaseFixAsk::Forbid, offer.clone()] {
let given = Given {
kind: kind.clone(),
stamp: STAMPED.to_string(),
base_fix,
};
let written = given.to_args();
for at in [0, 2, 4, written.len()] {
let mut args = written.clone();
args.insert(at, URL);
args.insert(at, "no-merge");
assert_eq!(read(&args).unwrap(), Some(given.clone()), "{args:?}");
}
}
}
}
#[test]
fn a_run_given_no_hidden_argument_is_no_child_run() {
assert_eq!(read(&[URL, "base-fix"]).unwrap(), None);
}
#[test]
fn hidden_arguments_that_are_repeated_have_no_value_or_lack_a_kind_or_stamp_are_rejected() {
let stamp = ["--stamp", STAMPED];
let ticket = ["--spec-branch", "issue-237"];
for (args, error) in [
(
vec![&ticket[..], &ticket, &stamp],
"repeated argument: --spec-branch",
),
(
vec![&ticket, &["--base-fix-into", "main"], &stamp],
"repeated argument: --base-fix-into",
),
(vec![&ticket, &stamp, &stamp], "repeated argument: --stamp"),
(
vec![&ticket, &stamp, &["--allow-base-fix", "--allow-base-fix"]],
"repeated argument: --allow-base-fix",
),
(
vec![
&ticket,
&stamp,
&["--offer-base-fix", "x", "--offer-base-fix", "y"],
],
"repeated argument: --offer-base-fix",
),
(
vec![
&ticket,
&stamp,
&["--allow-base-fix", "--offer-base-fix", "x"],
],
"--allow-base-fix and --offer-base-fix can't be used together",
),
(
vec![
&ticket,
&stamp,
&["--offer-base-fix", "x", "--allow-base-fix"],
],
"--allow-base-fix and --offer-base-fix can't be used together",
),
(vec![&[URL, "--spec-branch"]], "missing Base branch"),
(vec![&[URL, "--base-fix-into"]], "missing Base branch"),
(vec![&ticket, &[URL, "--stamp"]], "missing stamp"),
(
vec![&ticket, &stamp, &[URL, "--offer-base-fix"]],
"missing command to offer",
),
(vec![&ticket, &[URL]], "missing --stamp"),
(vec![&["--base-fix-into", "main", URL]], "missing --stamp"),
(
vec![&stamp, &[URL]],
"only a child Run is given --stamp, --allow-base-fix or --offer-base-fix",
),
(
vec![&[URL, "--allow-base-fix"]],
"only a child Run is given --stamp, --allow-base-fix or --offer-base-fix",
),
(
vec![&[URL, "--offer-base-fix", "x"]],
"only a child Run is given --stamp, --allow-base-fix or --offer-base-fix",
),
] {
let mut args: Vec<&str> = args.concat();
if !args.contains(&URL) {
args.push(URL);
}
let rejection = match read(&args) {
Ok(given) => panic!("{args:?}: not rejected, read {given:?}"),
Err(error) => format!("{error:#}"),
};
assert_eq!(rejection, error, "{args:?}");
}
}
#[test]
fn a_child_run_that_cannot_be_started_names_the_executable_that_was_tried() {
let cause = cause_of_not_starting_from(Path::new("/no/such/thirdshift"));
assert!(
cause.starts_with("could not start the Run for #248 from /no/such/thirdshift: "),
"{cause}"
);
}
#[test]
fn a_child_run_that_cannot_be_started_from_a_link_names_the_file_it_leads_to() {
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("exe");
std::os::unix::fs::symlink("/no/such/thirdshift (deleted)", &link).unwrap();
let cause = cause_of_not_starting_from(&link);
assert!(
cause.starts_with(&format!(
"could not start the Run for #248 from {} (/no/such/thirdshift (deleted)): ",
link.display()
)),
"{cause}"
);
}
}