1use crate::error::{EngineError, Result};
6use crate::event_log::EventLog;
7use crate::paths::MissionPaths;
8use crate::queue::{self, QueueEntry};
9use crate::reducer;
10use crate::ticket::{Ticket, TicketState};
11use crate::types::MissionStatus;
12use std::collections::HashSet;
13use std::path::Path;
14
15fn mission_status(repo_root: &Path, mission_id: &str) -> Option<MissionStatus> {
19 let paths = MissionPaths::new(repo_root, mission_id);
20 let events = EventLog::read_events(&paths.events_file()).ok()?;
21 let state = reducer::fold(&events).ok()?;
22 Some(state.mission.status)
23}
24
25pub fn unsatisfied_blockers(repo_root: &Path, slug: &str) -> Result<Vec<String>> {
30 Ticket::ensure_valid_slug(slug)?;
31 let path = Ticket::tickets_dir(repo_root).join(format!("{slug}.md"));
32 let ticket = Ticket::load(&path)?;
33
34 let mut unsatisfied = Vec::new();
35 for blocker in &ticket.blocked_by {
36 Ticket::ensure_valid_slug(blocker)?;
37 let satisfied = Ticket::mission_for(repo_root, blocker)
38 .and_then(|mission_id| mission_status(repo_root, &mission_id))
39 .is_some_and(|status| status == MissionStatus::Complete);
40 if !satisfied {
41 unsatisfied.push(blocker.clone());
42 }
43 }
44 Ok(unsatisfied)
45}
46
47pub fn is_blocked(repo_root: &Path, slug: &str) -> Result<bool> {
50 Ok(!unsatisfied_blockers(repo_root, slug)?.is_empty())
51}
52
53pub fn detect_cycle(repo_root: &Path, slug: &str) -> Result<Option<Vec<String>>> {
58 Ticket::ensure_valid_slug(slug)?;
59 let mut path = vec![slug.to_string()];
60 let mut on_path: HashSet<String> = HashSet::new();
61 on_path.insert(slug.to_string());
62 dfs(repo_root, slug, &mut path, &mut on_path)
63}
64
65fn dfs(
66 repo_root: &Path,
67 current: &str,
68 path: &mut Vec<String>,
69 on_path: &mut HashSet<String>,
70) -> Result<Option<Vec<String>>> {
71 let ticket_path = Ticket::tickets_dir(repo_root).join(format!("{current}.md"));
72 let Ok(ticket) = Ticket::load(&ticket_path) else {
73 return Ok(None);
75 };
76
77 for blocker in &ticket.blocked_by {
78 Ticket::ensure_valid_slug(blocker)?;
79
80 if on_path.contains(blocker) {
81 let mut cycle = path.clone();
82 cycle.push(blocker.clone());
83 let start = cycle
84 .iter()
85 .position(|s| s == blocker)
86 .expect("blocker is in on_path, so it is in path");
87 return Ok(Some(cycle[start..].to_vec()));
88 }
89
90 path.push(blocker.clone());
91 on_path.insert(blocker.clone());
92 if let Some(cycle) = dfs(repo_root, blocker, path, on_path)? {
93 return Ok(Some(cycle));
94 }
95 path.pop();
96 on_path.remove(blocker);
97 }
98
99 Ok(None)
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ApprovedTicket {
111 pub mission_id: String,
112 pub priority: u8,
113}
114
115pub fn approve_ticket(
131 repo_root: &Path,
132 slug: &str,
133 explicit_mission: Option<&str>,
134 force: bool,
135) -> Result<ApprovedTicket> {
136 Ticket::ensure_valid_slug(slug)?;
137 let ticket_path = Ticket::tickets_dir(repo_root).join(format!("{slug}.md"));
138 let ticket = Ticket::load(&ticket_path)?;
139
140 if let Some(defer_until) = ticket.defer_until {
144 if defer_until > chrono::Utc::now() && !force {
145 return Err(EngineError::InvalidState(format!(
146 "cannot approve {slug}: deferred until {} (re-run with --force \
147 to queue it anyway)",
148 defer_until.to_rfc3339()
149 )));
150 }
151 }
152
153 let state = Ticket::read_state(repo_root, slug);
154 if !matches!(state, TicketState::Review | TicketState::Parked) {
155 return Err(EngineError::InvalidState(format!(
156 "ticket '{slug}' is {} — only a REVIEW or PARKED ticket \
157 can be queued; run `kranz draft {slug}` first",
158 ticket_state_label(state)
159 )));
160 }
161
162 if let Some(cycle) = detect_cycle(repo_root, slug)? {
163 return Err(EngineError::InvalidState(format!(
164 "blocked-by cycle: {}",
165 cycle.join(" -> ")
166 )));
167 }
168 let unsatisfied = unsatisfied_blockers(repo_root, slug)?;
169 if !unsatisfied.is_empty() && !force {
170 return Err(EngineError::InvalidState(format!(
171 "cannot approve {slug}: blocked by {} (its mission is not Complete)",
172 unsatisfied.join(", ")
173 )));
174 }
175
176 let mission_id = match explicit_mission {
177 Some(id) => id.to_string(),
178 None => Ticket::mission_for(repo_root, slug)
179 .or_else(|| find_mission_for_ticket(repo_root, &ticket))
180 .ok_or_else(|| {
181 EngineError::InvalidState(format!(
182 "could not find the drafted mission for ticket '{slug}' automatically — \
183 pass one explicitly (see `kranz missions`)"
184 ))
185 })?,
186 };
187
188 let entry = queue::enqueue(
189 repo_root,
190 QueueEntry {
191 mission_id,
192 ticket_slug: Some(slug.to_string()),
193 priority: ticket.priority,
194 seq: 0,
195 },
196 )?;
197 Ticket::write_state(repo_root, slug, TicketState::Queued, None)?;
198 Ok(ApprovedTicket {
199 mission_id: entry.mission_id,
200 priority: entry.priority,
201 })
202}
203
204fn ticket_state_label(state: TicketState) -> &'static str {
211 match state {
212 TicketState::New => "NEW",
213 TicketState::Drafting => "DRAFTING",
214 TicketState::NeedsContext => "NEEDS-CONTEXT",
215 TicketState::WrongPlan => "WRONG-PLAN",
216 TicketState::Review => "REVIEW",
217 TicketState::Queued => "QUEUED",
218 TicketState::Running => "RUNNING",
219 TicketState::Done => "DONE",
220 TicketState::Failed => "FAILED",
221 TicketState::Parked => "PARKED",
222 TicketState::Superseded => "SUPERSEDED",
223 TicketState::Wontfix => "WONTFIX",
224 }
225}
226
227fn find_mission_for_ticket(repo_root: &Path, ticket: &Ticket) -> Option<String> {
230 let goal = ticket.mission_goal();
231 let mut best: Option<(std::time::SystemTime, String)> = None;
232 for id in MissionPaths::list_missions(repo_root) {
233 let paths = MissionPaths::new(repo_root, &id);
234 let Ok(events) = EventLog::read_events(&paths.events_file()) else {
235 continue;
236 };
237 let Ok(state) = reducer::fold(&events) else {
238 continue;
239 };
240 if state.mission.goal != goal {
241 continue;
242 }
243 let mtime = std::fs::metadata(paths.events_file())
244 .and_then(|m| m.modified())
245 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
246 if best.as_ref().is_none_or(|(t, _)| mtime >= *t) {
247 best = Some((mtime, id));
248 }
249 }
250 best.map(|(_, id)| id)
251}