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::poll;
use crate::progress;
use crate::spec_run::READY_FOR_AGENT;
const BASE_FIX_LABEL: &str = "base-fix";
const LABELS: [(&str, &str); 2] = [
(BASE_FIX_LABEL, "A Base fix: CI is red on a Base branch"),
(READY_FOR_AGENT, "Ready for an agent to take on"),
];
pub const REPORT: &str = "Base fix: ";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BaseFixAsk {
Allow,
Forbid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnInheritedFailures {
FailTheRun,
StartBaseFix,
IsBaseFix,
}
pub struct BaseFix {
on_inherited_failures: OnInheritedFailures,
taken: Option<Taken>,
}
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 = match (child, ask) {
(Some(Kind::BaseFix { .. }), _) => OnInheritedFailures::IsBaseFix,
(_, BaseFixAsk::Allow) => OnInheritedFailures::StartBaseFix,
(_, BaseFixAsk::Forbid) => OnInheritedFailures::FailTheRun,
};
BaseFix {
on_inherited_failures,
taken: None,
}
}
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 {
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 => 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: Vec<Check> = github::checks_on(issue, base_commit)?
.into_iter()
.filter(|check| {
check.state == CheckState::Failed
&& inherited.iter().any(|failed| failed.name == check.name)
})
.collect();
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(())
}
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 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, BASE_FIX_LABEL)?
.into_iter()
.filter(|(open, title)| {
open.number != issue.number
&& title
.strip_prefix(&before_checks)
.is_some_and(|checks| inherited.iter().all(|check| names(checks, &check.name)))
})
.map(|(open, _)| open)
.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
)
}