use crate::{
scm::{CiStatus, MergeReadiness, PrState, ReviewDecision},
types::SessionStatus,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScmObservation {
pub state: PrState,
pub ci: CiStatus,
pub review: ReviewDecision,
pub readiness: MergeReadiness,
}
pub fn derive_scm_status(
current: SessionStatus,
obs: Option<&ScmObservation>,
) -> Option<SessionStatus> {
let next = match obs {
None => status_without_pr(current)?,
Some(obs) => status_with_pr(obs),
};
(next != current).then_some(next)
}
const fn is_pr_track(status: SessionStatus) -> bool {
matches!(
status,
SessionStatus::PrOpen
| SessionStatus::CiFailed
| SessionStatus::ReviewPending
| SessionStatus::ChangesRequested
| SessionStatus::Approved
| SessionStatus::Mergeable
| SessionStatus::MergeFailed
)
}
fn status_without_pr(current: SessionStatus) -> Option<SessionStatus> {
is_pr_track(current).then_some(SessionStatus::Working)
}
fn status_with_pr(obs: &ScmObservation) -> SessionStatus {
if matches!(obs.state, PrState::Merged) {
return SessionStatus::Merged;
}
if matches!(obs.state, PrState::Closed) {
return SessionStatus::Killed;
}
if obs.readiness.is_ready() {
return SessionStatus::Mergeable;
}
if matches!(obs.ci, CiStatus::Failing) {
return SessionStatus::CiFailed;
}
if matches!(obs.review, ReviewDecision::ChangesRequested) {
return SessionStatus::ChangesRequested;
}
if matches!(obs.review, ReviewDecision::Approved) {
return SessionStatus::Approved;
}
if matches!(obs.review, ReviewDecision::Pending) {
return SessionStatus::ReviewPending;
}
SessionStatus::PrOpen
}
#[cfg(test)]
mod tests {
use super::*;
fn readiness_ready() -> MergeReadiness {
MergeReadiness {
mergeable: true,
ci_passing: true,
approved: true,
no_conflicts: true,
blockers: vec![],
}
}
fn readiness_blocked() -> MergeReadiness {
MergeReadiness {
mergeable: false,
ci_passing: false,
approved: false,
no_conflicts: true,
blockers: vec!["CI is failing".into()],
}
}
fn obs(
state: PrState,
ci: CiStatus,
review: ReviewDecision,
readiness: MergeReadiness,
) -> ScmObservation {
ScmObservation {
state,
ci,
review,
readiness,
}
}
#[test]
fn no_pr_and_working_is_a_no_op() {
assert_eq!(derive_scm_status(SessionStatus::Working, None), None);
}
#[test]
fn no_pr_and_spawning_is_a_no_op() {
assert_eq!(derive_scm_status(SessionStatus::Spawning, None), None);
}
#[test]
fn no_pr_drops_pr_open_back_to_working() {
assert_eq!(
derive_scm_status(SessionStatus::PrOpen, None),
Some(SessionStatus::Working)
);
}
const ALL_SESSION_STATUSES: &[SessionStatus] = &[
SessionStatus::Spawning,
SessionStatus::Working,
SessionStatus::NeedsInput,
SessionStatus::Idle,
SessionStatus::Stuck,
SessionStatus::PrOpen,
SessionStatus::CiFailed,
SessionStatus::ReviewPending,
SessionStatus::ChangesRequested,
SessionStatus::Approved,
SessionStatus::Mergeable,
SessionStatus::MergeFailed,
SessionStatus::Cleanup,
SessionStatus::Merged,
SessionStatus::Killed,
SessionStatus::Terminated,
SessionStatus::Done,
SessionStatus::Errored,
];
#[test]
fn all_session_statuses_list_is_exhaustive() {
for status in ALL_SESSION_STATUSES {
match status {
SessionStatus::Spawning
| SessionStatus::Working
| SessionStatus::NeedsInput
| SessionStatus::Idle
| SessionStatus::Stuck
| SessionStatus::PrOpen
| SessionStatus::CiFailed
| SessionStatus::ReviewPending
| SessionStatus::ChangesRequested
| SessionStatus::Approved
| SessionStatus::Mergeable
| SessionStatus::MergeFailed
| SessionStatus::Cleanup
| SessionStatus::Merged
| SessionStatus::Killed
| SessionStatus::Terminated
| SessionStatus::Done
| SessionStatus::Errored => {}
}
}
}
#[test]
fn no_pr_drops_every_pr_track_status_back_to_working() {
for &from in ALL_SESSION_STATUSES {
let got = derive_scm_status(from, None);
if is_pr_track(from) {
assert_eq!(
got,
Some(SessionStatus::Working),
"{from:?} is PR-track; detect_pr(None) should drop to Working"
);
} else {
assert_eq!(
got, None,
"{from:?} is not PR-track; detect_pr(None) must be a no-op"
);
}
}
}
#[test]
fn no_pr_and_terminal_is_a_no_op() {
for term in [
SessionStatus::Killed,
SessionStatus::Terminated,
SessionStatus::Done,
SessionStatus::Cleanup,
SessionStatus::Errored,
SessionStatus::Merged,
] {
assert_eq!(
derive_scm_status(term, None),
None,
"{term:?} must stay terminal"
);
}
}
#[test]
fn merged_pr_transitions_working_to_merged() {
let o = obs(
PrState::Merged,
CiStatus::Passing,
ReviewDecision::Approved,
readiness_ready(),
);
assert_eq!(
derive_scm_status(SessionStatus::Working, Some(&o)),
Some(SessionStatus::Merged)
);
}
#[test]
fn already_merged_session_with_merged_pr_is_a_no_op() {
let o = obs(
PrState::Merged,
CiStatus::Passing,
ReviewDecision::Approved,
readiness_ready(),
);
assert_eq!(derive_scm_status(SessionStatus::Merged, Some(&o)), None);
}
#[test]
fn closed_pr_transitions_working_to_killed() {
let o = obs(
PrState::Closed,
CiStatus::None,
ReviewDecision::None,
readiness_blocked(),
);
assert_eq!(
derive_scm_status(SessionStatus::Working, Some(&o)),
Some(SessionStatus::Killed)
);
}
#[test]
fn fully_ready_pr_becomes_mergeable() {
let o = obs(
PrState::Open,
CiStatus::Passing,
ReviewDecision::Approved,
readiness_ready(),
);
assert_eq!(
derive_scm_status(SessionStatus::PrOpen, Some(&o)),
Some(SessionStatus::Mergeable)
);
}
#[test]
fn changes_requested_beats_ci_failing() {
let o = obs(
PrState::Open,
CiStatus::Failing,
ReviewDecision::ChangesRequested,
readiness_blocked(),
);
assert_eq!(
derive_scm_status(SessionStatus::PrOpen, Some(&o)),
Some(SessionStatus::CiFailed)
);
}
#[test]
fn ci_failing_with_pending_review_is_ci_failed() {
let o = obs(
PrState::Open,
CiStatus::Failing,
ReviewDecision::Pending,
readiness_blocked(),
);
assert_eq!(
derive_scm_status(SessionStatus::PrOpen, Some(&o)),
Some(SessionStatus::CiFailed)
);
}
#[test]
fn approved_but_ci_pending_is_approved_not_mergeable() {
let readiness = MergeReadiness {
mergeable: false,
ci_passing: false,
approved: true,
no_conflicts: true,
blockers: vec!["CI is pending".into()],
};
let o = obs(
PrState::Open,
CiStatus::Pending,
ReviewDecision::Approved,
readiness,
);
assert_eq!(
derive_scm_status(SessionStatus::PrOpen, Some(&o)),
Some(SessionStatus::Approved)
);
}
#[test]
fn plain_open_pr_with_no_decision_is_pr_open() {
let o = obs(
PrState::Open,
CiStatus::Pending,
ReviewDecision::None,
readiness_blocked(),
);
assert_eq!(
derive_scm_status(SessionStatus::Working, Some(&o)),
Some(SessionStatus::PrOpen)
);
}
#[test]
fn review_pending_when_review_required_and_not_mergeable() {
let o = obs(
PrState::Open,
CiStatus::Pending,
ReviewDecision::Pending,
readiness_blocked(),
);
assert_eq!(
derive_scm_status(SessionStatus::PrOpen, Some(&o)),
Some(SessionStatus::ReviewPending)
);
}
#[test]
fn identical_status_returns_none() {
let o = obs(
PrState::Open,
CiStatus::Pending,
ReviewDecision::None,
readiness_blocked(),
);
assert_eq!(derive_scm_status(SessionStatus::PrOpen, Some(&o)), None);
}
#[test]
fn ci_failed_stays_ci_failed_while_ci_still_failing() {
let o = obs(
PrState::Open,
CiStatus::Failing,
ReviewDecision::Pending,
readiness_blocked(),
);
assert_eq!(derive_scm_status(SessionStatus::CiFailed, Some(&o)), None);
}
#[test]
fn ci_failed_transitions_back_to_pr_open_when_ci_recovers() {
let o = obs(
PrState::Open,
CiStatus::Pending,
ReviewDecision::None,
readiness_blocked(),
);
assert_eq!(
derive_scm_status(SessionStatus::CiFailed, Some(&o)),
Some(SessionStatus::PrOpen)
);
}
#[test]
fn mergeable_drops_back_to_ci_failed_if_ci_flips_red() {
let o = obs(
PrState::Open,
CiStatus::Failing,
ReviewDecision::Approved,
readiness_blocked(),
);
assert_eq!(
derive_scm_status(SessionStatus::Mergeable, Some(&o)),
Some(SessionStatus::CiFailed)
);
}
#[test]
fn changes_requested_transitions_up_to_mergeable_when_all_green() {
let o = obs(
PrState::Open,
CiStatus::Passing,
ReviewDecision::Approved,
readiness_ready(),
);
assert_eq!(
derive_scm_status(SessionStatus::ChangesRequested, Some(&o)),
Some(SessionStatus::Mergeable)
);
}
#[test]
fn merge_failed_re_promotes_to_mergeable_on_next_ready_observation() {
let o = obs(
PrState::Open,
CiStatus::Passing,
ReviewDecision::Approved,
readiness_ready(),
);
assert_eq!(
derive_scm_status(SessionStatus::MergeFailed, Some(&o)),
Some(SessionStatus::Mergeable)
);
}
#[test]
fn merge_failed_drops_to_ci_failed_when_ci_flips_red() {
let o = obs(
PrState::Open,
CiStatus::Failing,
ReviewDecision::Approved,
readiness_blocked(),
);
assert_eq!(
derive_scm_status(SessionStatus::MergeFailed, Some(&o)),
Some(SessionStatus::CiFailed)
);
}
#[test]
fn merge_failed_drops_to_changes_requested_when_review_dismissed() {
let o = obs(
PrState::Open,
CiStatus::Passing,
ReviewDecision::ChangesRequested,
readiness_blocked(),
);
assert_eq!(
derive_scm_status(SessionStatus::MergeFailed, Some(&o)),
Some(SessionStatus::ChangesRequested)
);
}
#[test]
fn merge_failed_drops_back_to_working_when_pr_disappears() {
assert_eq!(
derive_scm_status(SessionStatus::MergeFailed, None),
Some(SessionStatus::Working)
);
}
#[test]
fn merge_failed_to_merged_when_out_of_band_merge_happens() {
let o = obs(
PrState::Merged,
CiStatus::Passing,
ReviewDecision::Approved,
readiness_ready(),
);
assert_eq!(
derive_scm_status(SessionStatus::MergeFailed, Some(&o)),
Some(SessionStatus::Merged)
);
}
#[test]
fn status_with_pr_never_produces_merge_failed() {
for &state in &[PrState::Open] {
for &ci in &[CiStatus::Passing, CiStatus::Failing, CiStatus::Pending] {
for &review in &[
ReviewDecision::Approved,
ReviewDecision::ChangesRequested,
ReviewDecision::Pending,
ReviewDecision::None,
] {
for readiness in [readiness_ready(), readiness_blocked()] {
let o = obs(state, ci, review, readiness);
let next = status_with_pr(&o);
assert_ne!(
next,
SessionStatus::MergeFailed,
"ladder must never produce MergeFailed (state={state:?}, ci={ci:?}, review={review:?})"
);
}
}
}
}
}
}