1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
//! Shared merge-ancestry derivation, reused by mission rows (REST
//! `/api/missions`) and the ticket projection's Delivered/Landed split.
//!
//! ONE probe (`git_ops::is_ancestor` via [`merged_bit`]) backs both call
//! sites — no separate git logic per surface.
use crate::event_log::EventLog;
use crate::git_ops::GitRepo;
use crate::paths::MissionPaths;
use crate::reducer;
use crate::ticket::{Ticket, TicketState};
use crate::types::{Mission, MissionStatus};
use std::path::Path;
/// Whether `mission`'s branch tip is an ancestor of the LIVE base branch tip
/// (not the pinned `base_sha` — merged-detection tracks whatever the base
/// branch has absorbed as of now). `None` when there is no mission branch, or
/// when any ref fails to resolve; a per-mission git failure here must not
/// fail the whole list.
pub fn merged_bit(repo: &GitRepo, mission: &Mission) -> Option<bool> {
merged_bit_for_branches(repo, &mission.mission_branch, &mission.base_branch)
}
/// [`merged_bit`] over bare branch names, for callers holding the
/// reducer-folded refs rather than the whole [`Mission`] (the outcomes
/// comparison fold's memoized per-mission inputs).
pub fn merged_bit_for_branches(
repo: &GitRepo,
mission_branch: &str,
base_branch: &str,
) -> Option<bool> {
if !repo.branch_exists(mission_branch).ok()? {
return None;
}
let mission_tip = repo.rev_parse(mission_branch).ok()?;
let base_tip = repo.rev_parse(base_branch).ok()?;
repo.is_ancestor(&mission_tip, &base_tip).ok()
}
/// The Delivered/Landed merge status of a `Done` ticket, or `None` when the
/// split doesn't apply.
///
/// Caller semantics (mirrors `apps/dashboard/src/lib/pipelineStage.ts:87-96`
/// exactly): `Some(false)` => Delivered (mission complete but its branch is
/// not yet merged); `Some(true)` => Landed (mission branch merged into
/// base); `None` => split not applicable — callers treat a Done ticket with
/// `None` as Landed (direct-fixed with no linked mission, or a mission that
/// is dead/unloadable).
pub fn ticket_merged(repo_root: &Path, slug: &str) -> Option<bool> {
if Ticket::read_state(repo_root, slug) != TicketState::Done {
return None;
}
let mission_id = Ticket::mission_for(repo_root, slug)?;
let paths = MissionPaths::new(repo_root, &mission_id);
if !paths.events_file().is_file() {
return None;
}
let events = EventLog::read_events(&paths.events_file()).ok()?;
let state = reducer::fold(&events).ok()?;
if state.mission.status != MissionStatus::Complete {
return None;
}
let repo = GitRepo::open(repo_root).ok()?;
merged_bit(&repo, &state.mission)
}