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