use std::path::{Path, PathBuf};
use anyhow::Context as _;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Poll {
Met(String),
Pending(String),
}
pub(crate) trait Condition {
fn poll(&self) -> anyhow::Result<Poll>;
}
pub(crate) struct RunCondition {
pid: Option<u32>,
handle: Option<PathBuf>,
}
impl RunCondition {
pub(crate) fn new(pid: Option<u32>, handle: Option<PathBuf>) -> Self {
Self { pid, handle }
}
fn resolve(&self) -> anyhow::Result<u32> {
if let Some(pid) = self.pid {
return Ok(pid);
}
let path = self
.handle
.as_deref()
.context("`tm wait --for run` needs --pid or --handle")?;
let raw = std::fs::read_to_string(path)
.with_context(|| format!("cannot read job handle {}", path.display()))?;
parse_handle_pid(&raw)
.with_context(|| format!("no PID found in job handle {}", path.display()))
}
}
impl Condition for RunCondition {
fn poll(&self) -> anyhow::Result<Poll> {
let pid = self.resolve()?;
if trusty_mpm::core::process::is_process_alive(pid) {
Ok(Poll::Pending(format!("pid {pid} still running")))
} else {
Ok(Poll::Met(format!("pid {pid} is gone")))
}
}
}
pub(crate) fn parse_handle_pid(raw: &str) -> Option<u32> {
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(pid) = line.parse::<u32>() {
return Some(pid);
}
if let Some((key, value)) = line.split_once('=')
&& key.trim().eq_ignore_ascii_case("pid")
&& let Ok(pid) = value.trim().parse::<u32>()
{
return Some(pid);
}
}
None
}
pub(crate) struct FileCondition {
path: PathBuf,
contains: Option<String>,
}
impl FileCondition {
pub(crate) fn new(path: PathBuf, contains: Option<String>) -> Self {
Self { path, contains }
}
}
impl Condition for FileCondition {
fn poll(&self) -> anyhow::Result<Poll> {
let shown = self.path.display();
if !self.path.exists() {
return Ok(Poll::Pending(format!("{shown} does not exist yet")));
}
let Some(needle) = self.contains.as_deref() else {
return Ok(Poll::Met(format!("{shown} exists")));
};
let bytes =
std::fs::read(&self.path).with_context(|| format!("cannot read sentinel {shown}"))?;
let text = String::from_utf8_lossy(&bytes);
if text.contains(needle) {
Ok(Poll::Met(format!("{shown} contains {needle:?}")))
} else {
Ok(Poll::Pending(format!(
"{shown} exists but does not contain {needle:?} yet"
)))
}
}
}
pub(crate) trait ChecksProbe {
fn pr_view_json(&self, pr: u64, repo: Option<&str>) -> anyhow::Result<String>;
}
pub(crate) struct GhChecksProbe;
impl ChecksProbe for GhChecksProbe {
fn pr_view_json(&self, pr: u64, repo: Option<&str>) -> anyhow::Result<String> {
let out = trusty_common::gh::GhCommand::new([
"pr",
"view",
&pr.to_string(),
"--json",
"state,statusCheckRollup",
])
.repo(repo)
.nonempty_stdout_blocking()
.with_context(|| format!("`gh pr view {pr}` failed"))?;
Ok(out)
}
}
pub(crate) struct CheckCondition<P: ChecksProbe> {
probe: P,
pr: u64,
repo: Option<String>,
allow_empty: bool,
}
impl<P: ChecksProbe> CheckCondition<P> {
pub(crate) fn new(probe: P, pr: u64, repo: Option<String>, allow_empty: bool) -> Self {
Self {
probe,
pr,
repo,
allow_empty,
}
}
}
impl<P: ChecksProbe> Condition for CheckCondition<P> {
fn poll(&self) -> anyhow::Result<Poll> {
let json = self.probe.pr_view_json(self.pr, self.repo.as_deref())?;
let view: PrView = serde_json::from_str(&json)
.with_context(|| format!("cannot parse `gh pr view {}` JSON", self.pr))?;
Ok(settle(&view, self.allow_empty))
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct PrView {
#[serde(default)]
state: Option<String>,
#[serde(default, rename = "statusCheckRollup")]
status_check_rollup: Option<Vec<RollupEntry>>,
}
#[derive(Debug, Deserialize)]
struct RollupEntry {
#[serde(default, rename = "__typename")]
typename: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
context: Option<String>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
conclusion: Option<String>,
#[serde(default)]
state: Option<String>,
}
impl RollupEntry {
fn label(&self) -> String {
self.name
.clone()
.or_else(|| self.context.clone())
.or_else(|| self.typename.clone())
.unwrap_or_else(|| "<unnamed check>".to_string())
}
fn settled(&self) -> bool {
let completed = self
.status
.as_deref()
.is_some_and(|s| s.eq_ignore_ascii_case("COMPLETED"))
&& self
.conclusion
.as_deref()
.is_some_and(|c| !c.trim().is_empty());
let terminal_state = self.state.as_deref().is_some_and(|s| {
["SUCCESS", "FAILURE", "ERROR"]
.iter()
.any(|t| s.eq_ignore_ascii_case(t))
});
completed || terminal_state
}
fn failed(&self) -> bool {
let bad = |v: &str| ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED"].contains(&v);
self.conclusion
.as_deref()
.is_some_and(|c| bad(&c.to_ascii_uppercase()))
|| self
.state
.as_deref()
.is_some_and(|s| bad(&s.to_ascii_uppercase()))
}
}
fn settle(view: &PrView, allow_empty: bool) -> Poll {
let pr_state = view.state.as_deref().unwrap_or("UNKNOWN");
if pr_state.eq_ignore_ascii_case("MERGED") || pr_state.eq_ignore_ascii_case("CLOSED") {
return Poll::Met(format!(
"PR is {} — nothing left to settle",
pr_state.to_ascii_uppercase()
));
}
let entries = view.status_check_rollup.as_deref().unwrap_or(&[]);
if entries.is_empty() {
return if allow_empty {
Poll::Met("no checks reported; --allow-empty-checks was passed".to_string())
} else {
Poll::Pending(
"no checks reported yet — an empty rollup is not settled (pass \
--allow-empty-checks for a PR that runs none)"
.to_string(),
)
};
}
let unsettled: Vec<String> = entries
.iter()
.filter(|e| !e.settled())
.map(RollupEntry::label)
.collect();
if unsettled.is_empty() {
let failed = entries.iter().filter(|e| e.failed()).count();
Poll::Met(format!(
"{} check(s) settled, {failed} failing (state={pr_state})",
entries.len()
))
} else {
Poll::Pending(format!(
"{} of {} check(s) unsettled: {}",
unsettled.len(),
entries.len(),
unsettled.join(", ")
))
}
}
pub(crate) fn build(args: &crate::cli::WaitArgs) -> anyhow::Result<Box<dyn Condition>> {
use crate::cli::WaitFor;
match args.condition {
WaitFor::Run => {
anyhow::ensure!(
!(args.pid.is_some() && args.handle.is_some()),
"`tm wait --for run` takes --pid OR --handle, not both"
);
anyhow::ensure!(
args.pid.is_some() || args.handle.is_some(),
"`tm wait --for run` needs --pid <n> or --handle <file>"
);
Ok(Box::new(RunCondition::new(args.pid, args.handle.clone())))
}
WaitFor::File => {
let path = args
.path
.clone()
.context("`tm wait --for file` needs --path <file>")?;
Ok(Box::new(FileCondition::new(path, args.contains.clone())))
}
WaitFor::Check => {
let pr = args
.pr
.context("`tm wait --for check` needs --pr <number>")?;
Ok(Box::new(CheckCondition::new(
GhChecksProbe,
pr,
args.repo.clone(),
args.allow_empty_checks,
)))
}
}
}
pub(crate) fn spec(args: &crate::cli::WaitArgs) -> String {
use crate::cli::WaitFor;
let abs = |p: &Path| {
std::path::absolute(p)
.unwrap_or_else(|_| p.to_path_buf())
.display()
.to_string()
};
match args.condition {
WaitFor::Run => match (&args.pid, &args.handle) {
(Some(pid), _) => format!("run:pid={pid}"),
(None, Some(h)) => format!("run:handle={}", abs(h)),
(None, None) => "run:unspecified".to_string(),
},
WaitFor::File => {
let path = args.path.as_deref().map(abs).unwrap_or_default();
match &args.contains {
Some(c) => format!("file:{path}:contains={c}"),
None => format!("file:{path}"),
}
}
WaitFor::Check => {
let repo = args.repo.as_deref().unwrap_or("<cwd-remote>");
format!("check:{repo}#{}", args.pr.unwrap_or_default())
}
}
}