1use crate::commands::{build_backend, load_config, run_mission_loop};
17use crate::output;
18use anyhow::{bail, Context, Result};
19use kranz_engine::deps;
20use kranz_engine::draft::{drive_draft, DraftOutcome};
21use kranz_engine::git_ops::GitRepo;
22use kranz_engine::orchestrator::MissionEngine;
23use kranz_engine::queue::{self, QueueEntry};
24use kranz_engine::ticket::{Ticket, TicketState};
25use std::path::{Path, PathBuf};
26
27pub use kranz_engine::draft::{draft_decision, split_questions, DraftDecision};
31
32pub use kranz_engine::work::{
37 next_work_action, ticket_state_for_mission, work_skip_for_failed_blocker, WorkAction,
38};
39
40pub fn ticket_template(title: &str, goal: Option<&str>) -> String {
48 Ticket::ticket_template(title, goal, None)
49}
50
51pub fn cmd_ticket_new(repo: &Path, slug: &str, title: &str, goal: Option<&str>) -> Result<PathBuf> {
55 Ticket::scaffold(repo, slug, title, goal, None).map_err(anyhow::Error::from)
56}
57
58pub fn ticket_state_label(state: TicketState) -> &'static str {
64 match state {
65 TicketState::New => "NEW",
66 TicketState::Drafting => "DRAFTING",
67 TicketState::NeedsContext => "NEEDS-CONTEXT",
68 TicketState::WrongPlan => "WRONG-PLAN",
69 TicketState::Review => "REVIEW",
70 TicketState::Queued => "QUEUED",
71 TicketState::Running => "RUNNING",
72 TicketState::Done => "DONE",
73 TicketState::Failed => "FAILED",
74 TicketState::Parked => "PARKED",
75 TicketState::Superseded => "SUPERSEDED",
78 TicketState::Wontfix => "WONTFIX",
79 }
80}
81
82pub fn ticket_terminal_label(repo: &Path, slug: &str, state: TicketState) -> &'static str {
90 if state != TicketState::Done {
91 return ticket_state_label(state);
92 }
93 match kranz_engine::merged::ticket_merged(repo, slug) {
94 Some(false) => "DELIVERED",
95 Some(true) | None => "LANDED",
96 }
97}
98
99pub struct TicketRow<'a> {
102 pub ticket: &'a Ticket,
103 pub label: &'static str,
104}
105
106pub fn render_ticket_list(rows: &[TicketRow<'_>]) -> String {
108 if rows.is_empty() {
109 return "no tickets\n".to_string();
110 }
111 let slug_w = rows
112 .iter()
113 .map(|r| r.ticket.slug.len())
114 .max()
115 .unwrap_or(4)
116 .max(4);
117 let state_w = rows.iter().map(|r| r.label.len()).max().unwrap_or(5).max(5);
118 let mut out = String::new();
119 out.push_str(&format!(
120 "{:<slug_w$} {:<3} {:<state_w$} {}\n",
121 "SLUG", "PRI", "STATE", "TITLE",
122 ));
123 for row in rows {
124 out.push_str(&format!(
125 "{:<slug_w$} {:<3} {:<state_w$} {}\n",
126 row.ticket.slug,
127 row.ticket.priority,
128 row.label,
129 output::one_line(&row.ticket.title, 60),
130 ));
131 }
132 out
133}
134
135pub fn render_ticket_ready(
140 ready: &[TicketRow<'_>],
141 deferred: &[TicketRow<'_>],
142 include_deferred: bool,
143) -> String {
144 let mut out = String::new();
145 if ready.is_empty() {
146 out.push_str("no ready tickets\n");
147 } else {
148 out.push_str(&render_ticket_list(ready));
149 }
150 if include_deferred && !deferred.is_empty() {
151 let slug_w = deferred
152 .iter()
153 .map(|r| r.ticket.slug.len())
154 .max()
155 .unwrap_or(4)
156 .max(4);
157 let state_w = deferred
158 .iter()
159 .map(|r| r.label.len())
160 .max()
161 .unwrap_or(5)
162 .max(5);
163 out.push_str("\ndeferred (not ready yet):\n");
164 out.push_str(&format!(
165 "{:<slug_w$} {:<3} {:<state_w$} {:<25} {}\n",
166 "SLUG", "PRI", "STATE", "DEFER-UNTIL", "TITLE",
167 ));
168 for row in deferred {
169 let until = row
170 .ticket
171 .defer_until
172 .map(|ts| ts.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
173 .unwrap_or_default();
174 out.push_str(&format!(
175 "{:<slug_w$} {:<3} {:<state_w$} {:<25} {}\n",
176 row.ticket.slug,
177 row.ticket.priority,
178 row.label,
179 until,
180 output::one_line(&row.ticket.title, 60),
181 ));
182 }
183 }
184 out
185}
186
187pub fn render_ticket_show(ticket: &Ticket, label: &str) -> String {
191 let clean = output::sanitize_untrusted;
195 let mut out = String::new();
196 out.push_str(&format!("ticket {} [{}]\n", clean(&ticket.slug), label));
197 out.push_str(&format!(" title: {}\n", clean(&ticket.title)));
198 out.push_str(&format!(" priority: {}\n", ticket.priority));
199 out.push_str(&format!(" schedule: {:?}\n", ticket.schedule));
200 if let Some(budget) = ticket.max_budget_usd {
201 out.push_str(&format!(" budget: ${budget:.2}\n"));
202 }
203 if !ticket.repo_refs.is_empty() {
204 out.push_str(&format!(
205 " refs: {}\n",
206 clean(&ticket.repo_refs.join(", "))
207 ));
208 }
209 if !ticket.blocked_by.is_empty() {
210 out.push_str(&format!(
211 " blocked-by: {}\n",
212 clean(&ticket.blocked_by.join(", "))
213 ));
214 }
215
216 if !ticket.goal.trim().is_empty() {
217 out.push_str("\n## Goal\n");
218 out.push_str(clean(ticket.goal.trim()).trim());
219 out.push('\n');
220 }
221 if !ticket.context.trim().is_empty() {
222 out.push_str("\n## Context\n");
223 out.push_str(clean(ticket.context.trim()).trim());
224 out.push('\n');
225 }
226 if !ticket.scoping_answers.is_empty() {
227 out.push_str("\n## Scoping answers\n");
228 for item in &ticket.scoping_answers {
229 out.push_str(&format!("- {}\n", clean(item)));
230 }
231 }
232 if !ticket.acceptance_hints.is_empty() {
233 out.push_str("\n## Acceptance hints\n");
234 for item in &ticket.acceptance_hints {
235 out.push_str(&format!("- {}\n", clean(item)));
236 }
237 }
238
239 if let Some(block) = section_block(&ticket.raw_body, "needs context") {
242 let block = clean(&block);
243 out.push('\n');
244 out.push_str(&block);
245 if !block.ends_with('\n') {
246 out.push('\n');
247 }
248 }
249 if let Some(block) = section_block(&ticket.raw_body, "wrong plan") {
252 let block = clean(&block);
253 out.push('\n');
254 out.push_str(&block);
255 if !block.ends_with('\n') {
256 out.push('\n');
257 }
258 }
259 out
260}
261
262fn section_block(body: &str, heading_prefix: &str) -> Option<String> {
267 let mut lines = body.lines().peekable();
268 let mut collecting = false;
269 let mut out: Vec<&str> = Vec::new();
270 for line in &mut lines {
271 let is_section = line.trim_start().starts_with("##");
272 if collecting && is_section {
273 break; }
275 if is_section
276 && line
277 .trim_start_matches('#')
278 .trim()
279 .to_ascii_lowercase()
280 .starts_with(heading_prefix)
281 {
282 collecting = true;
283 }
284 if collecting {
285 out.push(line);
286 }
287 }
288 if out.is_empty() {
289 None
290 } else {
291 Some(out.join("\n").trim_end().to_string())
292 }
293}
294
295pub fn render_queue(entries: &[QueueEntry], busy_with: Option<&str>) -> String {
302 let mut out = String::new();
303 match busy_with {
304 Some(id) => out.push_str(&format!("repo busy: mission {id} is running\n")),
305 None => out.push_str("repo idle\n"),
306 }
307 if entries.is_empty() {
308 out.push_str("queue empty\n");
309 return out;
310 }
311 out.push_str(&format!(
312 "{:<3} {:<3} {:<14} {}\n",
313 "#", "PRI", "MISSION", "TICKET"
314 ));
315 for (i, entry) in entries.iter().enumerate() {
316 out.push_str(&format!(
317 "{:<3} {:<3} {:<14} {}\n",
318 i + 1,
319 entry.priority,
320 entry.mission_id,
321 entry.ticket_slug.as_deref().unwrap_or("-"),
322 ));
323 }
324 out
325}
326
327pub fn cmd_ticket_list(repo: &Path) -> String {
333 let tickets = Ticket::list(repo);
334 let rows: Vec<TicketRow<'_>> = tickets
335 .iter()
336 .map(|t| {
337 let state = Ticket::read_state(repo, &t.slug);
338 TicketRow {
339 ticket: t,
340 label: ticket_terminal_label(repo, &t.slug, state),
341 }
342 })
343 .collect();
344 render_ticket_list(&rows)
345}
346
347pub fn cmd_ticket_ready(repo: &Path, include_deferred: bool) -> String {
354 let now = chrono::Utc::now();
355 let tickets = Ticket::list(repo);
356 let mut ready: Vec<TicketRow<'_>> = Vec::new();
357 let mut deferred: Vec<TicketRow<'_>> = Vec::new();
358 for t in &tickets {
359 let state = Ticket::read_state(repo, &t.slug);
360 if !matches!(
361 state,
362 TicketState::New
363 | TicketState::NeedsContext
364 | TicketState::WrongPlan
365 | TicketState::Review
366 | TicketState::Parked
367 ) {
368 continue;
369 }
370 let row = TicketRow {
371 ticket: t,
372 label: ticket_terminal_label(repo, &t.slug, state),
373 };
374 if t.is_ready_at(now) {
375 ready.push(row);
376 } else {
377 deferred.push(row);
378 }
379 }
380 render_ticket_ready(&ready, &deferred, include_deferred)
381}
382
383pub fn cmd_ticket_show(repo: &Path, slug: &str) -> Result<String> {
385 let path = Ticket::tickets_dir(repo).join(format!("{slug}.md"));
386 if !path.is_file() {
387 bail!("ticket '{slug}' not found at {}", path.display());
388 }
389 let ticket = Ticket::load(&path)?;
390 let state = Ticket::read_state(repo, slug);
391 let label = ticket_terminal_label(repo, slug, state);
392 Ok(render_ticket_show(&ticket, label))
393}
394
395pub fn cmd_queue(repo: &Path) -> String {
397 let entries = queue::list(repo);
398 let busy = queue::is_repo_busy(repo);
399 render_queue(&entries, busy.as_deref())
400}
401
402pub fn cmd_queue_remove(repo: &Path, mission_id: &str) -> Result<String> {
406 if !queue::remove(repo, mission_id) {
407 bail!("mission '{mission_id}' is not queued");
408 }
409 Ok(format!("removed {mission_id} from the queue\n"))
410}
411
412fn load_ticket(repo: &Path, slug: &str) -> Result<Ticket> {
414 let path = Ticket::tickets_dir(repo).join(format!("{slug}.md"));
415 if !path.is_file() {
416 bail!("ticket '{slug}' not found at {}", path.display());
417 }
418 Ok(Ticket::load(&path)?)
419}
420
421fn config_for_ticket(
424 mut cfg: kranz_engine::types::MissionConfig,
425 ticket: &Ticket,
426) -> kranz_engine::types::MissionConfig {
427 if let Some(budget) = ticket.max_budget_usd {
428 cfg.orchestrator.max_budget_usd = Some(budget);
429 }
430 cfg
431}
432
433pub async fn cmd_draft(
452 repo: PathBuf,
453 slug: &str,
454 yes: bool,
455 from_mission: Option<&str>,
456 dangerously_allow_all: bool,
457) -> Result<i32> {
458 if let Some(mission_id) = from_mission {
459 Ticket::seed_traced_from_mission(&repo, slug, mission_id)
460 .with_context(|| format!("seeding traced-from-mission on ticket '{slug}'"))?;
461 println!("ticket '{slug}' traced from mission {mission_id}");
462 }
463 let ticket = load_ticket(&repo, slug)?;
464 let cfg = config_for_ticket(load_config(&repo, dangerously_allow_all)?, &ticket);
465 let backend = build_backend(&cfg)?;
466
467 let original_branch = GitRepo::open(&repo)
470 .ok()
471 .and_then(|g| g.current_branch().ok());
472 let mut engine = MissionEngine::create(backend, repo.clone(), &ticket.mission_goal(), cfg)?;
473 println!(
474 "drafting ticket '{slug}' as mission {}",
475 engine.mission_id()
476 );
477
478 let drive = drive_draft(&mut engine, &repo, &ticket, yes)
479 .await
480 .map_err(|e| crate::commands::augment_limit_hint(e.into()))
481 .with_context(|| format!("drafting ticket '{slug}'"))?;
482
483 if let Some(seed) = &drive.seed_reply {
486 println!("orchestrator: {}", output::one_line(seed, 200));
487 }
488
489 let mission_branch = engine.state().mission.mission_branch.clone();
490 if let Some(plan) = &drive.plan {
495 println!("{}", output::render_plan(plan));
496 drop(engine);
497 restore_draft_checkout(&repo, original_branch.as_deref(), &mission_branch);
498 } else {
499 drop(engine);
500 }
501
502 match drive.outcome {
503 DraftOutcome::NeedsContext {
504 mission_id: _,
505 questions,
506 } => {
507 println!("ticket '{slug}' needs context — the orchestrator asked:");
508 for q in &questions {
509 println!(" - {}", output::sanitize_untrusted(q));
510 }
511 println!(
512 "answer them in {} then run `kranz draft {slug}` again.",
513 Ticket::tickets_dir(&repo)
514 .join(format!("{slug}.md"))
515 .display()
516 );
517 }
518 DraftOutcome::WrongPlan { mission_id, reason } => {
519 println!(
520 "ticket '{slug}' WRONG-PLAN escalation (mission {mission_id}) — the planner \
521 can produce a plan but believes it is likely wrong:"
522 );
523 println!(" {}", output::sanitize_untrusted(&reason));
524 println!(
525 "edit or re-scope {} then run `kranz draft {slug}` again.",
526 Ticket::tickets_dir(&repo)
527 .join(format!("{slug}.md"))
528 .display()
529 );
530 }
531 DraftOutcome::PlanAsProse { mission_id } => {
532 println!(
533 "ticket '{slug}' NOT queued: mission {mission_id}'s orchestrator produced a \
534 plan but emitted it as prose instead of through the plan channel, so nothing \
535 was queued. Run `kranz draft {slug}` again."
536 );
537 }
538 DraftOutcome::Enqueued { mission_id } => {
539 println!(
540 "plan committed on {mission_branch}; mission {mission_id} approved and QUEUED. \
541 Run it with `kranz work`."
542 );
543 }
544 DraftOutcome::ParkedForReview {
545 mission_id,
546 mission_branch: _,
547 } => {
548 println!(
549 "plan committed on {mission_branch} for review; mission {mission_id} parked. \
550 Review it, then run `kranz ticket approve {slug}` to queue it \
551 (or `kranz plan --mission {mission_id}` to reshape)."
552 );
553 }
554 }
555 Ok(0)
556}
557
558pub async fn cmd_decompose(
570 repo: PathBuf,
571 goal: &str,
572 yes: bool,
573 dangerously_allow_all: bool,
574) -> Result<i32> {
575 let cfg = load_config(&repo, dangerously_allow_all)?;
576 let backend = build_backend(&cfg)?;
577 let drive =
578 kranz_engine::decompose::drive_decompose(backend.as_ref(), &repo, goal, &cfg, yes).await?;
579
580 print!("{}", kranz_engine::decompose::render_preview(&drive.nodes));
581 match &drive.written {
582 None => println!(
583 "dry run — nothing written; re-run with --yes to write these {} ticket(s).",
584 drive.nodes.len()
585 ),
586 Some(paths) => {
587 for path in paths {
588 println!("wrote {}", path.display());
589 }
590 println!(
591 "draft each node with `kranz draft <slug>` — deps gating runs the DAG in \
592 dependency order."
593 );
594 }
595 }
596 Ok(0)
597}
598
599pub fn cmd_ticket_queue(
616 repo: &Path,
617 slug: &str,
618 explicit_mission: Option<&str>,
619 force: bool,
620) -> Result<i32> {
621 let approved = deps::approve_ticket(repo, slug, explicit_mission, force)?;
622 println!(
623 "ticket '{slug}' QUEUED (mission {}, priority {}). Run it with `kranz work`.",
624 approved.mission_id, approved.priority
625 );
626 Ok(0)
627}
628
629pub fn cmd_ticket_approve(
633 repo: &Path,
634 slug: &str,
635 explicit_mission: Option<&str>,
636 force: bool,
637) -> Result<i32> {
638 eprintln!("warning: `kranz ticket approve` is deprecated, use `kranz ticket queue` instead");
639 cmd_ticket_queue(repo, slug, explicit_mission, force)
640}
641
642pub fn cmd_ticket_migrate_state(repo: &Path, yes: bool) -> Result<i32> {
649 let report = kranz_engine::migrate_state::fold_sidecar_states(repo, yes)?;
650 print!("{}", render_migration_report(&report));
651 Ok(0)
652}
653
654pub fn render_migration_report(report: &kranz_engine::migrate_state::MigrationReport) -> String {
659 use kranz_engine::migrate_state::FoldAction;
660 let verb = if report.applied {
661 "folded"
662 } else {
663 "would fold"
664 };
665 let mut out = String::new();
666 for action in &report.actions {
667 match action {
668 FoldAction::Fold { slug, note } => {
669 out.push_str(&format!(
670 "{verb} {slug}: .status done → frontmatter state: done"
671 ));
672 if let Some(note) = note {
673 out.push_str(&format!(" (state-note: {})", output::one_line(note, 60)));
674 }
675 out.push('\n');
676 }
677 FoldAction::SkipDirty { slug } => {
678 out.push_str(&format!(
679 "SKIP {slug}: uncommitted changes — in-flight work; commit it, then re-run to fold\n"
680 ));
681 }
682 FoldAction::AlreadyMigrated { slug } => {
683 out.push_str(&format!(
684 "skip {slug}: frontmatter already carries a state: key\n"
685 ));
686 }
687 FoldAction::NoTerminalSidecar {
688 slug,
689 sidecar: Some(state),
690 } => {
691 out.push_str(&format!(
692 "leave {slug}: sidecar state {} is pipeline, not operator lifecycle\n",
693 ticket_state_label(*state)
694 ));
695 }
696 FoldAction::NoTerminalSidecar { sidecar: None, .. } => {}
697 }
698 }
699 out.push_str(&format!(
700 "{}: {} {}, {} dirty-skipped, {} already migrated, {} left alone (no terminal sidecar)\n",
701 if report.applied { "applied" } else { "dry run" },
702 report.folds(),
703 if report.applied { "folded" } else { "to fold" },
704 report.dirty_skips(),
705 report.already_migrated(),
706 report.left_alone(),
707 ));
708 if !report.applied && report.folds() > 0 {
709 out.push_str("nothing written — re-run with --yes to apply the fold.\n");
710 }
711 out
712}
713
714fn restore_work_checkout(repo: &Path, original: Option<&str>) {
721 let Some(original) = original else { return };
722 if original.starts_with("kranz/mission-") {
723 return;
724 }
725 let Ok(git) = GitRepo::open(repo) else { return };
726 if git.current_branch().ok().as_deref() == Some(original) {
727 return;
728 }
729 match git.is_clean_tracked() {
730 Ok(true) => match git.checkout(original) {
731 Ok(()) => println!("checkout restored to {original}"),
732 Err(e) => eprintln!("warning: could not restore checkout to {original}: {e}"),
733 },
734 Ok(false) => {
735 eprintln!("warning: checkout left in place: tracked files have uncommitted changes")
736 }
737 Err(e) => {
738 eprintln!("warning: could not probe the working tree ({e}); checkout left in place")
739 }
740 }
741}
742
743fn restore_draft_checkout(repo: &Path, original: Option<&str>, mission_branch: &str) {
748 let Some(original) = original else { return };
749 if original == mission_branch {
750 return;
751 }
752 let Ok(git) = GitRepo::open(repo) else { return };
753 match git.is_clean_tracked() {
754 Ok(true) => match git.checkout(original) {
755 Ok(()) => println!("checkout restored to {original}"),
756 Err(e) => eprintln!("warning: could not restore checkout to {original}: {e}"),
757 },
758 Ok(false) => eprintln!(
759 "warning: leaving checkout on {mission_branch}: tracked files have \
760 uncommitted changes"
761 ),
762 Err(e) => eprintln!(
763 "warning: could not probe the working tree ({e}); checkout left on {mission_branch}"
764 ),
765 }
766}
767
768pub async fn cmd_work(repo: PathBuf, once: bool, expected: Option<String>) -> Result<i32> {
778 let dispatch_branch = GitRepo::open(&repo)
782 .ok()
783 .and_then(|g| g.current_branch().ok());
784 let recovered = queue::recover_dead_claims(&repo);
789 if recovered > 0 {
790 println!(
791 "recovered {recovered} claimed queue entr{} from dead dispatchers",
792 if recovered == 1 { "y" } else { "ies" }
793 );
794 }
795
796 let report =
797 kranz_engine::work::drain_queue_expected(&repo, once, expected.as_deref(), |mission_id| {
798 let repo = repo.clone();
799 async move {
800 println!("running mission {mission_id} from the queue");
801 let status = drive_mission(repo, &mission_id).await;
802 if let Err(e) = &status {
803 eprintln!("kranz: mission {mission_id} errored: {e:#}");
804 }
805 status
806 }
807 })
808 .await?;
809
810 if report.stopped_busy {
811 return Ok(0);
816 }
817 if let Some(front) = report.expected_mismatch {
818 println!(
819 "queue front changed to {front}; expected {} — nothing ran",
820 expected.as_deref().unwrap_or("-")
821 );
822 restore_work_checkout(&repo, dispatch_branch.as_deref());
823 return Ok(0);
824 }
825 if report.ran.is_empty() && report.skipped.is_empty() && report.parked.is_empty() {
826 println!("queue empty — nothing to do.");
827 } else {
828 if !report.parked.is_empty() {
829 println!("parked (backend not ready): {}", report.parked.join(", "));
830 }
831 if !report.skipped.is_empty() {
832 println!("skipped: {}", report.skipped.join(", "));
833 }
834 if !report.ran.is_empty() {
835 println!("ran: {}", report.ran.join(", "));
836 }
837 }
838 restore_work_checkout(&repo, dispatch_branch.as_deref());
839 Ok(0)
840}
841
842async fn drive_mission(repo: PathBuf, mission_id: &str) -> Result<i32> {
846 run_mission_loop(
847 repo,
848 mission_id.to_string(),
849 kranz_engine::event_log::LockForce::No,
850 false,
851 )
852 .await
853}
854
855#[cfg(test)]
860mod tests {
861 use super::*;
862 use kranz_engine::events::{Event, EventKind};
863 use std::sync::Once;
864 use tempfile::TempDir;
865
866 static ENV_ISOLATION: Once = Once::new();
867
868 fn isolate_git_env() {
872 ENV_ISOLATION.call_once(|| {
873 let missing = std::env::temp_dir().join(format!(
874 "kranz-cli-backlog-test-no-config-{}",
875 std::process::id()
876 ));
877 std::env::set_var("GIT_CONFIG_GLOBAL", &missing);
878 std::env::set_var("GIT_CONFIG_SYSTEM", &missing);
879 if let Ok(ceiling) = std::fs::canonicalize(std::env::temp_dir()) {
880 std::env::set_var("GIT_CEILING_DIRECTORIES", ceiling);
881 }
882 });
883 }
884
885 fn git_available() -> bool {
886 std::process::Command::new("git")
887 .arg("--version")
888 .output()
889 .map(|o| o.status.success())
890 .unwrap_or(false)
891 }
892
893 fn setup() -> bool {
894 isolate_git_env();
895 if git_available() {
896 true
897 } else {
898 kranz_engine::test_capability::skip(
899 kranz_engine::test_capability::capability::GIT,
900 "git is not on PATH",
901 );
902 false
903 }
904 }
905
906 fn raw_git(dir: &Path, args: &[&str]) {
907 let out = std::process::Command::new("git")
908 .args(args)
909 .current_dir(dir)
910 .output()
911 .expect("spawn git");
912 assert!(
913 out.status.success(),
914 "git {args:?} failed: {}",
915 String::from_utf8_lossy(&out.stderr)
916 );
917 }
918
919 fn init_repo() -> (TempDir, PathBuf, String) {
922 let dir = TempDir::new().unwrap();
923 let init = std::process::Command::new("git")
924 .args(["init", "-b", "main"])
925 .current_dir(dir.path())
926 .output()
927 .expect("spawn git init");
928 if !init.status.success() {
929 raw_git(dir.path(), &["init"]);
930 raw_git(dir.path(), &["symbolic-ref", "HEAD", "refs/heads/main"]);
931 }
932 raw_git(dir.path(), &["config", "user.name", "test"]);
933 raw_git(dir.path(), &["config", "user.email", "test@example.com"]);
934 std::fs::write(dir.path().join("README.md"), "seed\n").unwrap();
935 raw_git(dir.path(), &["add", "-A"]);
936 raw_git(dir.path(), &["commit", "-m", "seed"]);
937 let root = std::fs::canonicalize(dir.path()).expect("canonicalize repo root");
938 let sha = {
939 let out = std::process::Command::new("git")
940 .args(["rev-parse", "HEAD"])
941 .current_dir(&root)
942 .output()
943 .expect("rev-parse HEAD");
944 String::from_utf8_lossy(&out.stdout).trim().to_string()
945 };
946 (dir, root, sha)
947 }
948
949 fn write_events(repo_root: &Path, mission_id: &str, kinds: Vec<EventKind>) {
950 let dir = repo_root.join(".kranz").join("missions").join(mission_id);
951 std::fs::create_dir_all(&dir).unwrap();
952 let mut lines = String::new();
953 for (i, kind) in kinds.into_iter().enumerate() {
954 let event = Event {
955 seq: (i + 1) as u64,
956 ts: chrono::Utc::now(),
957 mission_id: mission_id.to_string(),
958 kind,
959 };
960 lines.push_str(&serde_json::to_string(&event).unwrap());
961 lines.push('\n');
962 }
963 std::fs::write(dir.join("events.jsonl"), lines).unwrap();
964 }
965
966 fn created(mission_branch: &str) -> EventKind {
967 EventKind::MissionCreated {
968 goal: "fixture mission".to_string(),
969 base_branch: "main".to_string(),
970 mission_branch: mission_branch.to_string(),
971 config: kranz_engine::types::MissionConfig::default(),
972 }
973 }
974
975 fn scaffold_done_ticket_with_mission(
979 repo_root: &Path,
980 slug: &str,
981 mission_id: &str,
982 base_sha: &str,
983 merge_into_base: bool,
984 ) {
985 Ticket::scaffold(repo_root, slug, "fixture ticket", None, None).unwrap();
986 Ticket::write_state(repo_root, slug, TicketState::Done, None).unwrap();
987 Ticket::record_mission(repo_root, slug, mission_id).unwrap();
988
989 let branch = format!("kranz/mission-{mission_id}");
990 raw_git(repo_root, &["checkout", "-b", &branch, base_sha]);
991 std::fs::write(repo_root.join("feature.txt"), "new feature\n").unwrap();
992 raw_git(repo_root, &["add", "--", "feature.txt"]);
993 raw_git(repo_root, &["commit", "-m", "add feature"]);
994 raw_git(repo_root, &["checkout", "main"]);
995 if merge_into_base {
996 raw_git(repo_root, &["merge", "--no-ff", "--no-edit", &branch]);
997 }
998
999 write_events(
1000 repo_root,
1001 mission_id,
1002 vec![created(&branch), EventKind::MissionCompleted {}],
1003 );
1004 }
1005
1006 #[test]
1007 fn cli_ticket_delivered_landed_when_done_and_unmerged() {
1008 if !setup() {
1009 return;
1010 }
1011 let (_dir, repo_root, base_sha) = init_repo();
1012 scaffold_done_ticket_with_mission(&repo_root, "unmerged", "m-unmerged", &base_sha, false);
1013
1014 let label = ticket_terminal_label(&repo_root, "unmerged", TicketState::Done);
1015 assert_eq!(label, "DELIVERED");
1016
1017 let ticket = load_ticket(&repo_root, "unmerged").unwrap();
1018 assert!(render_ticket_show(&ticket, label).contains("[DELIVERED]"));
1019 }
1020
1021 #[test]
1022 fn cli_ticket_delivered_landed_when_done_and_merged() {
1023 if !setup() {
1024 return;
1025 }
1026 let (_dir, repo_root, base_sha) = init_repo();
1027 scaffold_done_ticket_with_mission(&repo_root, "merged", "m-merged", &base_sha, true);
1028
1029 let label = ticket_terminal_label(&repo_root, "merged", TicketState::Done);
1030 assert_eq!(label, "LANDED");
1031
1032 let ticket = load_ticket(&repo_root, "merged").unwrap();
1033 assert!(render_ticket_show(&ticket, label).contains("[LANDED]"));
1034 }
1035
1036 #[test]
1037 fn cli_ticket_delivered_landed_when_done_and_no_mission() {
1038 if !setup() {
1039 return;
1040 }
1041 let (_dir, repo_root, _base_sha) = init_repo();
1042 Ticket::scaffold(&repo_root, "no-mission", "fixture ticket", None, None).unwrap();
1043 Ticket::write_state(&repo_root, "no-mission", TicketState::Done, None).unwrap();
1044
1045 let label = ticket_terminal_label(&repo_root, "no-mission", TicketState::Done);
1046 assert_eq!(label, "LANDED", "Done with no linked mission => Landed");
1047 }
1048
1049 #[test]
1050 fn cli_ticket_delivered_landed_leaves_non_terminal_states_unchanged() {
1051 if !setup() {
1052 return;
1053 }
1054 let (_dir, repo_root, _base_sha) = init_repo();
1055 Ticket::scaffold(&repo_root, "queued", "fixture ticket", None, None).unwrap();
1056 Ticket::write_state(&repo_root, "queued", TicketState::Queued, None).unwrap();
1057
1058 let label = ticket_terminal_label(&repo_root, "queued", TicketState::Queued);
1059 assert_eq!(label, "QUEUED");
1060 assert_eq!(label, ticket_state_label(TicketState::Queued));
1061
1062 for state in [
1063 TicketState::New,
1064 TicketState::Drafting,
1065 TicketState::NeedsContext,
1066 TicketState::WrongPlan,
1067 TicketState::Review,
1068 TicketState::Running,
1069 TicketState::Failed,
1070 TicketState::Parked,
1071 ] {
1072 assert_eq!(
1073 ticket_terminal_label(&repo_root, "queued", state),
1074 ticket_state_label(state),
1075 "non-Done state {state:?} must render exactly as ticket_state_label"
1076 );
1077 }
1078 }
1079}