use std::fmt;
use std::fs::File;
use std::path::PathBuf;
use anyhow::{Result, bail};
use crate::child_run::{self, Ended, Kind};
use crate::ci;
use crate::git::Git;
use crate::github::{self, Check, CheckState};
use crate::issue::IssueUrl;
use crate::labels::{Label, Labels, READY_FOR_AGENT};
use crate::poll;
use crate::progress;
pub const BASE_FIX: Label = Label::new("base-fix", "A Base fix: CI is red on a Base branch");
pub fn is_issue(labels: &Labels) -> bool {
labels.has(BASE_FIX)
}
const LABELS: [Label; 2] = [BASE_FIX, READY_FOR_AGENT];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaseFixAsk {
Allow,
Forbid,
Undecided { retry: String },
}
const BASE_CHECK: &str = "Base check";
const RETRY_WITH: &str = "Retry with";
const OR_SET: &str = "Or set";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Advice {
pub label: &'static str,
pub value: String,
}
impl Advice {
pub fn is_line(message: &str) -> bool {
[BASE_CHECK, RETRY_WITH, OR_SET].iter().any(|label| {
message
.strip_prefix(label)
.is_some_and(|value| value.starts_with(": "))
})
}
}
impl fmt::Display for Advice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.label, self.value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnInheritedFailures {
FailTheRun,
StartBaseFix,
IsBaseFix,
}
pub struct BaseFix {
on_inherited_failures: OnInheritedFailures,
retry: Option<String>,
taken: Option<Taken>,
advice: Vec<Advice>,
}
struct Taken {
issue: IssueUrl,
started: bool,
reached: bool,
}
impl Taken {
fn awaited_end(&self) -> &'static str {
if self.started { "merged" } else { "closed" }
}
}
impl BaseFix {
pub fn new(child: Option<&Kind>, ask: BaseFixAsk) -> Self {
let (on_inherited_failures, retry) = match (child, ask) {
(Some(Kind::BaseFix { .. }), _) => (OnInheritedFailures::IsBaseFix, None),
(_, BaseFixAsk::Allow) => (OnInheritedFailures::StartBaseFix, None),
(_, BaseFixAsk::Forbid) => (OnInheritedFailures::FailTheRun, None),
(_, BaseFixAsk::Undecided { retry }) => (OnInheritedFailures::FailTheRun, Some(retry)),
};
BaseFix {
on_inherited_failures,
retry,
taken: None,
advice: Vec::new(),
}
}
pub fn sees_inherited_failures(&self) -> bool {
self.on_inherited_failures != OnInheritedFailures::IsBaseFix
}
fn may_start(&self) -> bool {
self.on_inherited_failures == OnInheritedFailures::StartBaseFix
}
pub fn ask_of_tickets(&self) -> BaseFixAsk {
if self.may_start() {
BaseFixAsk::Allow
} else if let Some(retry) = &self.retry {
BaseFixAsk::Undecided {
retry: retry.clone(),
}
} else {
BaseFixAsk::Forbid
}
}
pub fn fix(
&mut self,
launch: &Git,
issue: &IssueUrl,
pr_url: &str,
base: &str,
base_commit: &str,
inherited: &[Check],
) -> Result<()> {
let checks = ci::check_names(inherited);
let base_at = ci::short(base_commit);
if self.taken.is_some() || !self.may_start() {
let even_after = match &self.taken {
Some(taken) => format!(
", even after Base fix {} {}",
taken.issue.url,
taken.awaited_end()
),
None => {
self.advice = self.advice_on(issue, base, base_commit, inherited);
String::new()
}
};
bail!(
"CI red on {checks}, which also fails on {base} at {base_at}{even_after}; \
fix {base} first"
);
}
let looking = launch.lock("thirdshift-base-fix.lock")?;
let fix = match open_fix_covering(issue, base, inherited)? {
Some(open) => match Mark::of(launch, open.number)? {
Mark::Left => {
progress::step(format_args!(
"Base fix #{} is open but no longer running; \
starting it again into {base}: {}",
open.number, open.url
));
open
}
mark => {
drop(looking);
return self.wait_on(open, mark, base);
}
},
None => {
let on_base = failed_on_base(issue, base_commit, inherited)?;
let fix = github::create_issue(
issue,
&issue_title(base, &checks),
&issue_body(issue, pr_url, base, base_at, &on_base),
&LABELS,
)?;
progress::step(format_args!(
"starting Base fix #{} into {base}: {}",
fix.number, fix.url
));
fix
}
};
let number = fix.number;
let running = Running::mark(launch, number)?;
drop(looking);
let taken = self.taken.insert(Taken {
issue: fix,
started: true,
reached: false,
});
let kind = Kind::BaseFix {
base: base.to_string(),
};
let child = child_run::start(&taken.issue, &kind, &BaseFixAsk::Forbid)?;
progress::step(format_args!("waiting on Base fix #{number}"));
match child_run::wait(number, child)? {
Ended::Reached { .. } => {
taken.reached = true;
running.merged();
progress::step(format_args!(
"Base fix #{number} merged; merging {base} in again"
));
Ok(())
}
Ended::Interrupted => bail!("interrupted"),
Ended::Failed { cause, log } => {
if let Some(log) = log {
progress::step(format_args!("Base fix #{number} session log: {log}"));
}
bail!("Base fix {} failed: {cause}", taken.issue.url)
}
}
}
fn wait_on(&mut self, open: IssueUrl, mark: Mark, base: &str) -> Result<()> {
let number = open.number;
progress::step(format_args!(
"waiting on Base fix #{number}, already open: {}",
open.url
));
let taken = self.taken.insert(Taken {
issue: open,
started: false,
reached: false,
});
let closed = poll::until(|| {
let ended = mark.has_ended();
let closed = !github::issue_is_open(&taken.issue)?;
Ok((closed || ended).then_some(closed))
})?;
if !closed {
bail!(
"Base fix {} ended with its issue still open",
taken.issue.url
);
}
taken.reached = true;
progress::step(format_args!(
"Base fix #{number} closed; merging {base} in again"
));
Ok(())
}
fn advice_on(
&self,
issue: &IssueUrl,
base: &str,
base_commit: &str,
inherited: &[Check],
) -> Vec<Advice> {
let on_base = failed_on_base(issue, base_commit, inherited).unwrap_or_else(|error| {
progress::step(format_args!(
"could not read the checks on {base}: {error:#}"
));
Vec::new()
});
let mut advice: Vec<Advice> = on_base
.iter()
.map(|check| Advice {
label: BASE_CHECK,
value: ci::check_with_url(check),
})
.collect();
if let Some(retry) = &self.retry {
advice.push(Advice {
label: RETRY_WITH,
value: retry.clone(),
});
advice.push(Advice {
label: OR_SET,
value: "base.fix = true in ~/.thirdshift/config.toml, \
to allow a Base fix for every Run on this machine"
.to_string(),
});
}
advice
}
pub fn into_advice(self) -> Vec<Advice> {
self.advice
}
pub fn report(&self) -> Option<String> {
let taken = self.taken.as_ref()?;
let not = if taken.reached { "" } else { "not " };
Some(format!("{} {not}{}", taken.issue.url, taken.awaited_end()))
}
}
fn mark_file(number: u64) -> String {
format!("thirdshift-base-fix-{number}.lock")
}
struct Running {
file: PathBuf,
_lock: File,
}
impl Running {
fn mark(launch: &Git, number: u64) -> Result<Self> {
let name = mark_file(number);
Ok(Running {
file: launch.common_dir()?.join(&name),
_lock: launch.lock(&name)?,
})
}
fn merged(self) {
let _ = std::fs::remove_file(&self.file);
}
}
enum Mark {
Running(File),
Left,
None,
}
impl Mark {
fn of(launch: &Git, number: u64) -> Result<Self> {
let Ok(file) = File::open(launch.common_dir()?.join(mark_file(number))) else {
return Ok(Mark::None);
};
let mark = Mark::Running(file);
Ok(if mark.has_ended() { Mark::Left } else { mark })
}
fn has_ended(&self) -> bool {
let Mark::Running(file) = self else {
return false;
};
let ended = file.try_lock_shared().is_ok();
if ended {
let _ = file.unlock();
}
ended
}
}
fn failed_on_base(issue: &IssueUrl, base_commit: &str, inherited: &[Check]) -> Result<Vec<Check>> {
let on_base = github::checks_on(issue, base_commit)?
.into_iter()
.filter(|check| {
check.state == CheckState::Failed
&& inherited.iter().any(|failed| failed.name == check.name)
})
.collect();
Ok(on_base)
}
fn issue_title(base: &str, checks: &str) -> String {
format!("CI red on {base}: {checks}")
}
fn open_fix_covering(
issue: &IssueUrl,
base: &str,
inherited: &[Check],
) -> Result<Option<IssueUrl>> {
let before_checks = issue_title(base, "");
let covering = github::open_issues_labelled(&issue.repo_slug(), BASE_FIX)?
.into_iter()
.filter(|open| {
open.issue.number != issue.number
&& open
.title
.strip_prefix(&before_checks)
.is_some_and(|checks| inherited.iter().all(|check| names(checks, &check.name)))
})
.map(|open| open.issue)
.min_by_key(|open| open.number);
Ok(covering)
}
fn names(checks: &str, name: &str) -> bool {
checks.match_indices(name).any(|(at, _)| {
let (before, after) = (&checks[..at], &checks[at + name.len()..]);
(before.is_empty() || before.ends_with(", "))
&& (after.is_empty() || after.starts_with(", "))
})
}
fn issue_body(
issue: &IssueUrl,
pr_url: &str,
base: &str,
base_at: &str,
failed: &[Check],
) -> String {
let checks = ci::check_list(failed);
format!(
"CI is red on `{base}` at {base_at}: these checks fail there, so every pull request into `{base}` inherits them.\n\
\n\
{checks}\
\n\
Found by the thirdshift Run on #{}, whose pull request is {pr_url}.\n\
\n\
Fix the checks on `{base}`. Do not skip, disable, or weaken tests or checks to make them pass.\n",
issue.number
)
}