1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use serde_yaml::{Mapping, Value};
6
7use crate::task::{Task, load_tasks_from_dir};
8
9use super::board::{read_workflow_metadata, write_workflow_metadata};
10use super::workflow::{ReviewDisposition, TaskState, can_transition};
11
12pub fn cmd_transition(board_dir: &Path, task_id: u32, target: &str) -> Result<()> {
13 transition_task(board_dir, task_id, target)?;
14 println!("Task #{task_id} transitioned to {}.", target.trim());
15 Ok(())
16}
17
18pub(crate) fn transition_task(board_dir: &Path, task_id: u32, target: &str) -> Result<()> {
19 let task_path = find_task_path(board_dir, task_id)?;
20 let task = Task::from_file(&task_path)?;
21 let current = parse_task_state(&task.status)?;
22 let target = parse_task_state(target)?;
23
24 can_transition(current, target).map_err(anyhow::Error::msg)?;
25
26 update_task_frontmatter(&task_path, |mapping| {
27 set_status(mapping, target);
28 if target != TaskState::Blocked {
29 clear_blocked(mapping);
30 }
31 })?;
32 Ok(())
33}
34
35pub fn cmd_assign(
36 board_dir: &Path,
37 task_id: u32,
38 exec_owner: Option<&str>,
39 review_owner: Option<&str>,
40) -> Result<()> {
41 if exec_owner.is_none() && review_owner.is_none() {
42 bail!("at least one owner must be provided");
43 }
44
45 assign_task_owners(board_dir, task_id, exec_owner, review_owner)?;
46
47 println!("Task #{task_id} ownership updated.");
48 Ok(())
49}
50
51pub(crate) fn assign_task_owners(
52 board_dir: &Path,
53 task_id: u32,
54 exec_owner: Option<&str>,
55 review_owner: Option<&str>,
56) -> Result<()> {
57 if exec_owner.is_none() && review_owner.is_none() {
58 bail!("at least one owner must be provided");
59 }
60
61 let task_path = find_task_path(board_dir, task_id)?;
62 update_task_frontmatter(&task_path, |mapping| {
63 if let Some(owner) = exec_owner {
64 set_optional_string(mapping, "claimed_by", normalize_optional(owner));
65 }
66 if let Some(owner) = review_owner {
67 set_optional_string(mapping, "review_owner", normalize_optional(owner));
68 }
69 })?;
70 Ok(())
71}
72
73pub(crate) fn unclaim_task(board_dir: &Path, task_id: u32) -> Result<()> {
75 let task_path = find_task_path(board_dir, task_id)?;
76 update_task_frontmatter(&task_path, |mapping| {
77 set_optional_string(mapping, "claimed_by", None);
78 set_optional_string(mapping, "review_owner", None);
79 set_optional_string(mapping, "claimed_at", None);
80 })?;
81 Ok(())
82}
83
84pub fn cmd_review(
85 board_dir: &Path,
86 task_id: u32,
87 disposition: &str,
88 feedback: Option<&str>,
89) -> Result<()> {
90 let task_path = find_task_path(board_dir, task_id)?;
91 let task = Task::from_file(&task_path)?;
92 let current = parse_task_state(&task.status)?;
93 let disposition = parse_review_disposition(disposition)?;
94 let target = match disposition {
95 ReviewDisposition::Approved => TaskState::Done,
96 ReviewDisposition::ChangesRequested => TaskState::InProgress,
97 ReviewDisposition::Rejected => TaskState::Archived,
98 };
99
100 can_transition(current, target).map_err(anyhow::Error::msg)?;
101
102 update_task_frontmatter(&task_path, |mapping| {
103 set_status(mapping, target);
104 clear_blocked(mapping);
105 if let Some(text) = feedback {
106 set_optional_string(mapping, "review_feedback", Some(text));
107 }
108 })?;
109
110 let mut metadata = read_workflow_metadata(&task_path)?;
111 metadata.outcome = Some(review_disposition_name(disposition).to_string());
112 if disposition == ReviewDisposition::Approved {
113 metadata.review_blockers.clear();
114 }
115 write_workflow_metadata(&task_path, &metadata)?;
116
117 if disposition == ReviewDisposition::ChangesRequested {
118 if let Some(text) = feedback {
119 if let Some(engineer) = task.claimed_by.as_deref() {
122 if let Some(project_root) = board_dir
123 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent())
126 {
128 let inbox_root = super::inbox::inboxes_root(project_root);
129 if let Ok(()) = queue_review_feedback(&inbox_root, engineer, task_id, text) {
130 println!("Review feedback delivered to {engineer}'s inbox.");
131 }
132 }
133 }
134 }
135 }
136
137 println!(
138 "Task #{task_id} review recorded as {}.",
139 review_disposition_name(disposition)
140 );
141 Ok(())
142}
143
144fn queue_review_feedback(
145 inbox_root: &Path,
146 engineer: &str,
147 task_id: u32,
148 feedback: &str,
149) -> Result<()> {
150 use super::inbox;
151 let message = format!("Review feedback for task #{task_id}: {feedback}");
152 let msg = inbox::InboxMessage::new_send("reviewer", engineer, &message);
153 inbox::deliver_to_inbox(inbox_root, &msg)?;
154 Ok(())
155}
156
157pub fn cmd_review_structured(
165 board_dir: &Path,
166 task_id: u32,
167 disposition: &str,
168 feedback: Option<&str>,
169 reviewer: &str,
170) -> Result<()> {
171 let task_path = find_task_path(board_dir, task_id)?;
172 let task = Task::from_file(&task_path)?;
173 let current = parse_task_state(&task.status)?;
174
175 let (target_state, disposition_str) = match disposition {
176 "approve" => (TaskState::Done, "approved"),
177 "request-changes" | "request_changes" => (TaskState::InProgress, "changes_requested"),
178 "reject" => (TaskState::Blocked, "rejected"),
179 other => bail!("unknown review disposition: {other}"),
180 };
181
182 can_transition(current, target_state).map_err(anyhow::Error::msg)?;
183
184 let now = chrono::Utc::now().to_rfc3339();
185 let default_reject_reason = format!("rejected by {reviewer}");
186
187 update_task_frontmatter(&task_path, |mapping| {
188 set_status(mapping, target_state);
189 set_optional_string(mapping, "review_disposition", Some(disposition_str));
190 set_optional_string(mapping, "reviewed_by", Some(reviewer));
191 set_optional_string(mapping, "reviewed_at", Some(&now));
192 if let Some(text) = feedback {
193 set_optional_string(mapping, "review_feedback", Some(text));
194 }
195 if target_state == TaskState::Blocked {
196 let reason = feedback.unwrap_or(&default_reject_reason);
197 set_optional_string(mapping, "blocked_on", Some(reason));
198 } else {
199 clear_blocked(mapping);
200 }
201 })?;
202
203 let mut metadata = read_workflow_metadata(&task_path)?;
205 metadata.outcome = Some(disposition_str.to_string());
206 if disposition == "approve" {
207 metadata.review_blockers.clear();
208 }
209 write_workflow_metadata(&task_path, &metadata)?;
210
211 if disposition == "request-changes" || disposition == "request_changes" {
213 if let Some(text) = feedback {
214 if let Some(engineer) = task.claimed_by.as_deref() {
215 if let Some(project_root) = board_dir
216 .parent()
217 .and_then(|p| p.parent())
218 .and_then(|p| p.parent())
219 {
220 let inbox_root = super::inbox::inboxes_root(project_root);
221 if let Ok(()) = queue_review_feedback(&inbox_root, engineer, task_id, text) {
222 println!("Review feedback delivered to {engineer}'s inbox.");
223 }
224 }
225 }
226 }
227 }
228
229 println!("Task #{task_id} review recorded as {disposition_str} by {reviewer}.");
230 Ok(())
231}
232
233pub fn cmd_update(board_dir: &Path, task_id: u32, fields: HashMap<String, String>) -> Result<()> {
234 if fields.is_empty() {
235 bail!("no workflow fields provided");
236 }
237
238 let task_path = find_task_path(board_dir, task_id)?;
239 let mut metadata = read_workflow_metadata(&task_path)?;
240 let mut metadata_changed = false;
241
242 if let Some(branch) = fields.get("branch") {
243 metadata.branch = normalize_optional(branch).map(str::to_string);
244 metadata_changed = true;
245 }
246 if let Some(commit) = fields.get("commit") {
247 metadata.commit = normalize_optional(commit).map(str::to_string);
248 metadata_changed = true;
249 }
250 if metadata_changed {
251 write_workflow_metadata(&task_path, &metadata)?;
252 }
253
254 let blocked_on = fields.get("blocked_on").cloned();
255 let should_clear_blocked = fields.contains_key("clear_blocked");
256 if blocked_on.is_some() || should_clear_blocked {
257 update_task_frontmatter(&task_path, |mapping| {
258 if should_clear_blocked {
259 clear_blocked(mapping);
260 }
261 if let Some(reason) = blocked_on.as_deref() {
262 let reason = normalize_optional(reason);
263 set_optional_string(mapping, "blocked", reason);
264 set_optional_string(mapping, "blocked_on", reason);
265 }
266 })?;
267 }
268
269 println!("Task #{task_id} metadata updated.");
270 Ok(())
271}
272
273pub fn cmd_schedule(
274 board_dir: &Path,
275 task_id: u32,
276 at: Option<&str>,
277 cron_expr: Option<&str>,
278 clear: bool,
279) -> Result<()> {
280 if !clear && at.is_none() && cron_expr.is_none() {
281 bail!("at least one of --at, --cron, or --clear is required");
282 }
283
284 if let Some(ts) = at {
286 chrono::DateTime::parse_from_rfc3339(ts)
287 .with_context(|| format!("invalid RFC3339 timestamp: {ts}"))?;
288 }
289
290 if let Some(expr) = cron_expr {
293 use std::str::FromStr;
294 let normalized = normalize_cron(expr);
295 cron::Schedule::from_str(&normalized)
296 .map_err(|e| anyhow::anyhow!("invalid cron expression: {e}"))?;
297 }
298
299 let task_path = find_task_path(board_dir, task_id)?;
300 update_task_frontmatter(&task_path, |mapping| {
301 if clear {
302 mapping.remove(yaml_key("scheduled_for"));
303 mapping.remove(yaml_key("cron_schedule"));
304 } else {
305 if let Some(ts) = at {
306 mapping.insert(yaml_key("scheduled_for"), Value::String(ts.to_string()));
307 }
308 if let Some(expr) = cron_expr {
309 mapping.insert(yaml_key("cron_schedule"), Value::String(expr.to_string()));
310 }
311 }
312 })?;
313
314 if clear {
315 println!("Task #{task_id} schedule cleared.");
316 } else {
317 let mut parts = Vec::new();
318 if let Some(ts) = at {
319 parts.push(format!("scheduled_for={ts}"));
320 }
321 if let Some(expr) = cron_expr {
322 parts.push(format!("cron_schedule={expr}"));
323 }
324 println!("Task #{task_id} schedule updated: {}", parts.join(", "));
325 }
326 Ok(())
327}
328
329pub fn cmd_auto_merge(task_id: u32, enabled: bool, project_root: &Path) -> Result<()> {
330 super::auto_merge::save_override(project_root, task_id, enabled)?;
331 let action = if enabled { "enabled" } else { "disabled" };
332 println!(
333 "Auto-merge {action} for task #{task_id}. The daemon will pick this up on its next completion evaluation."
334 );
335 Ok(())
336}
337
338pub(crate) fn find_task_path(board_dir: &Path, task_id: u32) -> Result<PathBuf> {
339 let tasks_dir = board_dir.join("tasks");
340 let tasks = load_tasks_from_dir(&tasks_dir)
341 .with_context(|| format!("failed to load tasks from {}", tasks_dir.display()))?;
342 tasks
343 .into_iter()
344 .find(|task| task.id == task_id)
345 .map(|task| task.source_path)
346 .with_context(|| format!("task #{task_id} not found in {}", tasks_dir.display()))
347}
348
349fn parse_task_state(value: &str) -> Result<TaskState> {
350 match value.trim().replace('-', "_").as_str() {
351 "backlog" => Ok(TaskState::Backlog),
352 "todo" => Ok(TaskState::Todo),
353 "in_progress" => Ok(TaskState::InProgress),
354 "review" => Ok(TaskState::Review),
355 "blocked" => Ok(TaskState::Blocked),
356 "done" => Ok(TaskState::Done),
357 "archived" => Ok(TaskState::Archived),
358 other => bail!("unknown task state `{other}`"),
359 }
360}
361
362fn parse_review_disposition(value: &str) -> Result<ReviewDisposition> {
363 match value.trim().replace('-', "_").as_str() {
364 "approved" => Ok(ReviewDisposition::Approved),
365 "changes_requested" => Ok(ReviewDisposition::ChangesRequested),
366 "rejected" => Ok(ReviewDisposition::Rejected),
367 other => bail!("unknown review disposition `{other}`"),
368 }
369}
370
371fn state_name(state: TaskState) -> &'static str {
372 match state {
373 TaskState::Backlog => "backlog",
374 TaskState::Todo => "todo",
375 TaskState::InProgress => "in-progress",
376 TaskState::Review => "review",
377 TaskState::Blocked => "blocked",
378 TaskState::Done => "done",
379 TaskState::Archived => "archived",
380 }
381}
382
383fn review_disposition_name(disposition: ReviewDisposition) -> &'static str {
384 match disposition {
385 ReviewDisposition::Approved => "approved",
386 ReviewDisposition::ChangesRequested => "changes_requested",
387 ReviewDisposition::Rejected => "rejected",
388 }
389}
390
391pub(crate) fn update_task_frontmatter<F>(task_path: &Path, mutator: F) -> Result<()>
392where
393 F: FnOnce(&mut Mapping),
394{
395 let content = std::fs::read_to_string(task_path)
396 .with_context(|| format!("failed to read {}", task_path.display()))?;
397 let (frontmatter, body) = split_task_frontmatter(&content)?;
398 let mut mapping: Mapping =
399 serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
400 mutator(&mut mapping);
401
402 let mut rendered =
403 serde_yaml::to_string(&mapping).context("failed to serialize task frontmatter")?;
404 if let Some(stripped) = rendered.strip_prefix("---\n") {
405 rendered = stripped.to_string();
406 }
407
408 let mut updated = String::from("---\n");
409 updated.push_str(&rendered);
410 if !updated.ends_with('\n') {
411 updated.push('\n');
412 }
413 updated.push_str("---\n");
414 updated.push_str(body);
415
416 std::fs::write(task_path, updated)
417 .with_context(|| format!("failed to write {}", task_path.display()))?;
418 Ok(())
419}
420
421fn split_task_frontmatter(content: &str) -> Result<(&str, &str)> {
422 let trimmed = content.trim_start();
423 if !trimmed.starts_with("---") {
424 bail!("task file missing YAML frontmatter (no opening ---)");
425 }
426
427 let after_open = &trimmed[3..];
428 let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
429 let close_pos = after_open
430 .find("\n---")
431 .context("task file missing closing --- for frontmatter")?;
432
433 let frontmatter = &after_open[..close_pos];
434 let body = &after_open[close_pos + 4..];
435 Ok((frontmatter, body.strip_prefix('\n').unwrap_or(body)))
436}
437
438fn set_status(mapping: &mut Mapping, state: TaskState) {
439 mapping.insert(
440 yaml_key("status"),
441 Value::String(state_name(state).to_string()),
442 );
443}
444
445fn clear_blocked(mapping: &mut Mapping) {
446 mapping.remove(yaml_key("blocked"));
447 mapping.remove(yaml_key("blocked_on"));
448}
449
450pub(crate) fn set_optional_string(mapping: &mut Mapping, key: &str, value: Option<&str>) {
451 let key = yaml_key(key);
452 match value {
453 Some(value) => {
454 mapping.insert(key, Value::String(value.to_string()));
455 }
456 None => {
457 mapping.remove(key);
458 }
459 }
460}
461
462pub(crate) fn yaml_key(name: &str) -> Value {
463 Value::String(name.to_string())
464}
465
466fn normalize_cron(expr: &str) -> String {
470 let fields: Vec<&str> = expr.split_whitespace().collect();
471 if fields.len() == 5 {
472 format!("0 {expr}")
473 } else {
474 expr.to_string()
475 }
476}
477
478fn normalize_optional(value: &str) -> Option<&str> {
479 let trimmed = value.trim();
480 if trimmed.is_empty() {
481 None
482 } else {
483 Some(trimmed)
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 fn write_task_file(dir: &Path, id: u32, status: &str) -> PathBuf {
492 let tasks_dir = dir.join("tasks");
493 std::fs::create_dir_all(&tasks_dir).unwrap();
494 let path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
495 std::fs::write(
496 &path,
497 format!(
498 "---\nid: {id}\ntitle: Task {id}\nstatus: {status}\npriority: high\nclass: standard\n---\n\nTask body.\n"
499 ),
500 )
501 .unwrap();
502 path
503 }
504
505 #[test]
506 fn transition_updates_task_status() {
507 let tmp = tempfile::tempdir().unwrap();
508 let board_dir = tmp.path();
509 let task_path = write_task_file(board_dir, 7, "todo");
510
511 cmd_transition(board_dir, 7, "in-progress").unwrap();
512
513 let task = Task::from_file(&task_path).unwrap();
514 assert_eq!(task.status, "in-progress");
515 }
516
517 #[test]
518 fn illegal_transition_returns_error() {
519 let tmp = tempfile::tempdir().unwrap();
520 let board_dir = tmp.path();
521 write_task_file(board_dir, 8, "backlog");
522
523 let error = cmd_transition(board_dir, 8, "done")
524 .unwrap_err()
525 .to_string();
526 assert!(error.contains("illegal task state transition"));
527 }
528
529 #[test]
530 fn assign_updates_execution_and_review_owners() {
531 let tmp = tempfile::tempdir().unwrap();
532 let board_dir = tmp.path();
533 let task_path = write_task_file(board_dir, 9, "todo");
534
535 cmd_assign(board_dir, 9, Some("eng-1-2"), Some("manager-1")).unwrap();
536
537 let task = Task::from_file(&task_path).unwrap();
538 assert_eq!(task.claimed_by.as_deref(), Some("eng-1-2"));
539 assert_eq!(task.review_owner.as_deref(), Some("manager-1"));
540 }
541
542 #[test]
543 fn review_updates_status_and_outcome() {
544 let tmp = tempfile::tempdir().unwrap();
545 let board_dir = tmp.path();
546 let task_path = write_task_file(board_dir, 10, "review");
547
548 cmd_review(board_dir, 10, "approved", None).unwrap();
549
550 let task = Task::from_file(&task_path).unwrap();
551 assert_eq!(task.status, "done");
552 let metadata = read_workflow_metadata(&task_path).unwrap();
553 assert_eq!(metadata.outcome.as_deref(), Some("approved"));
554 }
555
556 #[test]
557 fn update_writes_board_metadata_and_block_reason() {
558 let tmp = tempfile::tempdir().unwrap();
559 let board_dir = tmp.path();
560 let task_path = write_task_file(board_dir, 11, "blocked");
561
562 let fields = HashMap::from([
563 ("branch".to_string(), "eng-1-2/task-11".to_string()),
564 ("commit".to_string(), "abc1234".to_string()),
565 ("blocked_on".to_string(), "waiting for review".to_string()),
566 ]);
567 cmd_update(board_dir, 11, fields).unwrap();
568
569 let metadata = read_workflow_metadata(&task_path).unwrap();
570 assert_eq!(metadata.branch.as_deref(), Some("eng-1-2/task-11"));
571 assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
572
573 let task = Task::from_file(&task_path).unwrap();
574 assert_eq!(task.blocked.as_deref(), Some("waiting for review"));
575 assert_eq!(task.blocked_on.as_deref(), Some("waiting for review"));
576
577 cmd_update(
578 board_dir,
579 11,
580 HashMap::from([("clear_blocked".to_string(), "true".to_string())]),
581 )
582 .unwrap();
583
584 let task = Task::from_file(&task_path).unwrap();
585 assert!(task.blocked.is_none());
586 assert!(task.blocked_on.is_none());
587 }
588
589 #[test]
590 fn update_requires_at_least_one_field() {
591 let tmp = tempfile::tempdir().unwrap();
592 let board_dir = tmp.path();
593 write_task_file(board_dir, 12, "todo");
594
595 let error = cmd_update(board_dir, 12, HashMap::new())
596 .unwrap_err()
597 .to_string();
598 assert!(error.contains("no workflow fields provided"));
599 }
600
601 #[test]
602 fn task_commands_work_without_orchestrator_runtime() {
603 let tmp = tempfile::tempdir().unwrap();
604 let board_dir = tmp.path();
605 let task_path = write_task_file(board_dir, 13, "todo");
606
607 cmd_assign(board_dir, 13, Some("eng-1-2"), Some("manager-1")).unwrap();
608 cmd_transition(board_dir, 13, "in-progress").unwrap();
609 cmd_transition(board_dir, 13, "review").unwrap();
610 cmd_update(
611 board_dir,
612 13,
613 HashMap::from([
614 ("branch".to_string(), "eng-1-2/task-13".to_string()),
615 ("commit".to_string(), "deadbeef".to_string()),
616 ]),
617 )
618 .unwrap();
619 cmd_review(board_dir, 13, "approved", None).unwrap();
620
621 let task = Task::from_file(&task_path).unwrap();
622 let metadata = read_workflow_metadata(&task_path).unwrap();
623 assert_eq!(task.status, "done");
624 assert_eq!(task.claimed_by.as_deref(), Some("eng-1-2"));
625 assert_eq!(task.review_owner.as_deref(), Some("manager-1"));
626 assert_eq!(metadata.branch.as_deref(), Some("eng-1-2/task-13"));
627 assert_eq!(metadata.commit.as_deref(), Some("deadbeef"));
628 assert_eq!(metadata.outcome.as_deref(), Some("approved"));
629 }
630
631 fn write_review_task_with_engineer(dir: &Path, id: u32, engineer: &str) -> PathBuf {
632 let tasks_dir = dir.join("tasks");
633 std::fs::create_dir_all(&tasks_dir).unwrap();
634 let path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
635 std::fs::write(
636 &path,
637 format!(
638 "---\nid: {id}\ntitle: Task {id}\nstatus: review\npriority: high\nclass: standard\nclaimed_by: {engineer}\n---\n\nTask body.\n"
639 ),
640 )
641 .unwrap();
642 path
643 }
644
645 #[test]
646 fn review_feedback_stored_in_task() {
647 let tmp = tempfile::tempdir().unwrap();
648 let board_dir = tmp.path();
649 let task_path = write_review_task_with_engineer(board_dir, 42, "eng-1-2");
650
651 cmd_review(
652 board_dir,
653 42,
654 "changes_requested",
655 Some("fix the error handling"),
656 )
657 .unwrap();
658
659 let content = std::fs::read_to_string(&task_path).unwrap();
660 assert!(
661 content.contains("fix the error handling"),
662 "feedback should be stored in task frontmatter"
663 );
664 }
665
666 #[test]
667 fn review_feedback_delivered_to_engineer() {
668 let tmp = tempfile::tempdir().unwrap();
669
670 let project_root = tmp.path().join("project");
672 let actual_board_dir = project_root
673 .join(".batty")
674 .join("team_config")
675 .join("board");
676 std::fs::create_dir_all(actual_board_dir.join("tasks")).unwrap();
677
678 let inbox_root = crate::team::inbox::inboxes_root(&project_root);
680 crate::team::inbox::init_inbox(&inbox_root, "eng-1-2").unwrap();
681
682 let task_path = actual_board_dir.join("tasks").join("042-task-42.md");
684 std::fs::write(
685 &task_path,
686 "---\nid: 42\ntitle: Task 42\nstatus: review\npriority: high\nclass: standard\nclaimed_by: eng-1-2\n---\n\nTask body.\n",
687 )
688 .unwrap();
689
690 cmd_review(
691 &actual_board_dir,
692 42,
693 "changes_requested",
694 Some("fix the error handling"),
695 )
696 .unwrap();
697
698 let pending = crate::team::inbox::pending_messages(&inbox_root, "eng-1-2").unwrap();
699 assert_eq!(pending.len(), 1);
700 assert!(
701 pending[0].body.contains("fix the error handling"),
702 "feedback message should be delivered to engineer inbox"
703 );
704 assert!(pending[0].body.contains("#42"));
705 }
706
707 #[test]
708 fn schedule_task_sets_scheduled_for() {
709 let tmp = tempfile::tempdir().unwrap();
710 let board_dir = tmp.path();
711 let task_path = write_task_file(board_dir, 60, "todo");
712
713 cmd_schedule(
714 board_dir,
715 60,
716 Some("2026-03-25T09:00:00-04:00"),
717 None,
718 false,
719 )
720 .unwrap();
721
722 let task = Task::from_file(&task_path).unwrap();
723 assert_eq!(
724 task.scheduled_for.as_deref(),
725 Some("2026-03-25T09:00:00-04:00")
726 );
727 assert!(task.cron_schedule.is_none());
728 }
729
730 #[test]
731 fn schedule_task_sets_cron_schedule() {
732 let tmp = tempfile::tempdir().unwrap();
733 let board_dir = tmp.path();
734 let task_path = write_task_file(board_dir, 61, "todo");
735
736 cmd_schedule(board_dir, 61, None, Some("0 9 * * *"), false).unwrap();
737
738 let task = Task::from_file(&task_path).unwrap();
739 assert!(task.scheduled_for.is_none());
740 assert_eq!(task.cron_schedule.as_deref(), Some("0 9 * * *"));
741 }
742
743 #[test]
744 fn schedule_task_clear_removes_fields() {
745 let tmp = tempfile::tempdir().unwrap();
746 let board_dir = tmp.path();
747 let task_path = write_task_file(board_dir, 62, "todo");
748
749 cmd_schedule(
751 board_dir,
752 62,
753 Some("2026-04-01T00:00:00Z"),
754 Some("0 9 * * 1"),
755 false,
756 )
757 .unwrap();
758 let task = Task::from_file(&task_path).unwrap();
759 assert!(task.scheduled_for.is_some());
760 assert!(task.cron_schedule.is_some());
761
762 cmd_schedule(board_dir, 62, None, None, true).unwrap();
764 let task = Task::from_file(&task_path).unwrap();
765 assert!(task.scheduled_for.is_none());
766 assert!(task.cron_schedule.is_none());
767 }
768
769 #[test]
770 fn schedule_task_sets_both() {
771 let tmp = tempfile::tempdir().unwrap();
772 let board_dir = tmp.path();
773 let task_path = write_task_file(board_dir, 63, "todo");
774
775 cmd_schedule(
776 board_dir,
777 63,
778 Some("2026-04-01T00:00:00Z"),
779 Some("0 9 * * 1"),
780 false,
781 )
782 .unwrap();
783
784 let task = Task::from_file(&task_path).unwrap();
785 assert_eq!(task.scheduled_for.as_deref(), Some("2026-04-01T00:00:00Z"));
786 assert_eq!(task.cron_schedule.as_deref(), Some("0 9 * * 1"));
787 }
788
789 #[test]
790 fn schedule_rejects_invalid_timestamp() {
791 let tmp = tempfile::tempdir().unwrap();
792 let board_dir = tmp.path();
793 write_task_file(board_dir, 64, "todo");
794
795 let err = cmd_schedule(board_dir, 64, Some("not-a-date"), None, false)
796 .unwrap_err()
797 .to_string();
798 assert!(err.contains("invalid RFC3339 timestamp"));
799 }
800
801 #[test]
802 fn schedule_rejects_invalid_cron() {
803 let tmp = tempfile::tempdir().unwrap();
804 let board_dir = tmp.path();
805 write_task_file(board_dir, 65, "todo");
806
807 let err = cmd_schedule(board_dir, 65, None, Some("not-a-cron"), false)
808 .unwrap_err()
809 .to_string();
810 assert!(err.contains("invalid cron expression"));
811 }
812
813 #[test]
814 fn schedule_requires_at_least_one_flag() {
815 let tmp = tempfile::tempdir().unwrap();
816 let board_dir = tmp.path();
817 write_task_file(board_dir, 66, "todo");
818
819 let err = cmd_schedule(board_dir, 66, None, None, false)
820 .unwrap_err()
821 .to_string();
822 assert!(err.contains("at least one of --at, --cron, or --clear"));
823 }
824
825 #[test]
828 fn structured_review_approve_stores_frontmatter_and_moves_to_done() {
829 let tmp = tempfile::tempdir().unwrap();
830 let board_dir = tmp.path();
831 let task_path = write_task_file(board_dir, 70, "review");
832
833 cmd_review_structured(board_dir, 70, "approve", None, "manager-1").unwrap();
834
835 let task = Task::from_file(&task_path).unwrap();
836 assert_eq!(task.status, "done");
837
838 let content = std::fs::read_to_string(&task_path).unwrap();
839 assert!(content.contains("review_disposition: approved"));
840 assert!(content.contains("reviewed_by: manager-1"));
841 assert!(content.contains("reviewed_at:"));
842
843 let metadata = read_workflow_metadata(&task_path).unwrap();
844 assert_eq!(metadata.outcome.as_deref(), Some("approved"));
845 }
846
847 #[test]
848 fn structured_review_request_changes_stores_feedback_and_moves_to_in_progress() {
849 let tmp = tempfile::tempdir().unwrap();
850 let board_dir = tmp.path();
851 let task_path = write_task_file(board_dir, 71, "review");
852
853 cmd_review_structured(
854 board_dir,
855 71,
856 "request-changes",
857 Some("fix the error handling"),
858 "manager-1",
859 )
860 .unwrap();
861
862 let task = Task::from_file(&task_path).unwrap();
863 assert_eq!(task.status, "in-progress");
864
865 let content = std::fs::read_to_string(&task_path).unwrap();
866 assert!(content.contains("review_disposition: changes_requested"));
867 assert!(content.contains("review_feedback: fix the error handling"));
868 assert!(content.contains("reviewed_by: manager-1"));
869 assert!(content.contains("reviewed_at:"));
870 }
871
872 #[test]
873 fn structured_review_reject_moves_to_blocked_with_reason() {
874 let tmp = tempfile::tempdir().unwrap();
875 let board_dir = tmp.path();
876 let task_path = write_task_file(board_dir, 72, "review");
877
878 cmd_review_structured(
879 board_dir,
880 72,
881 "reject",
882 Some("does not meet requirements"),
883 "manager-1",
884 )
885 .unwrap();
886
887 let task = Task::from_file(&task_path).unwrap();
888 assert_eq!(task.status, "blocked");
889
890 let content = std::fs::read_to_string(&task_path).unwrap();
891 assert!(content.contains("review_disposition: rejected"));
892 assert!(content.contains("review_feedback: does not meet requirements"));
893 assert!(content.contains("reviewed_by: manager-1"));
894 assert!(content.contains("blocked_on: does not meet requirements"));
895 }
896
897 #[test]
898 fn structured_review_reject_without_feedback_uses_default_reason() {
899 let tmp = tempfile::tempdir().unwrap();
900 let board_dir = tmp.path();
901 let task_path = write_task_file(board_dir, 73, "review");
902
903 cmd_review_structured(board_dir, 73, "reject", None, "manager-1").unwrap();
904
905 let task = Task::from_file(&task_path).unwrap();
906 assert_eq!(task.status, "blocked");
907
908 let content = std::fs::read_to_string(&task_path).unwrap();
909 assert!(content.contains("blocked_on: rejected by manager-1"));
910 }
911
912 #[test]
913 fn structured_review_rejects_non_review_state() {
914 let tmp = tempfile::tempdir().unwrap();
915 let board_dir = tmp.path();
916 write_task_file(board_dir, 74, "in-progress");
917
918 let err = cmd_review_structured(board_dir, 74, "approve", None, "manager-1")
919 .unwrap_err()
920 .to_string();
921 assert!(err.contains("illegal task state transition"));
922 }
923
924 #[test]
925 fn structured_review_feedback_delivered_to_engineer_inbox() {
926 let tmp = tempfile::tempdir().unwrap();
927
928 let project_root = tmp.path().join("project");
930 let actual_board_dir = project_root
931 .join(".batty")
932 .join("team_config")
933 .join("board");
934 std::fs::create_dir_all(actual_board_dir.join("tasks")).unwrap();
935
936 let inbox_root = crate::team::inbox::inboxes_root(&project_root);
938 crate::team::inbox::init_inbox(&inbox_root, "eng-1-2").unwrap();
939
940 let task_path = actual_board_dir.join("tasks").join("075-task-75.md");
942 std::fs::write(
943 &task_path,
944 "---\nid: 75\ntitle: Task 75\nstatus: review\npriority: high\nclass: standard\nclaimed_by: eng-1-2\n---\n\nTask body.\n",
945 )
946 .unwrap();
947
948 cmd_review_structured(
949 &actual_board_dir,
950 75,
951 "request-changes",
952 Some("add more tests"),
953 "manager-1",
954 )
955 .unwrap();
956
957 let pending = crate::team::inbox::pending_messages(&inbox_root, "eng-1-2").unwrap();
958 assert_eq!(pending.len(), 1);
959 assert!(pending[0].body.contains("add more tests"));
960 assert!(pending[0].body.contains("#75"));
961 }
962}