1use std::collections::{BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use chrono::{DateTime, Duration as ChronoDuration, Utc};
6use serde_yaml::{Mapping, Value};
7
8use crate::task::Task;
9
10use super::board::{read_workflow_metadata, write_workflow_metadata};
11use super::workflow::{ReviewDisposition, TaskState, can_transition};
12
13pub fn cmd_transition(board_dir: &Path, task_id: u32, target: &str) -> Result<()> {
14 transition_task(board_dir, task_id, target)?;
15 println!("Task #{task_id} transitioned to {}.", target.trim());
16 Ok(())
17}
18
19pub(crate) fn transition_task(board_dir: &Path, task_id: u32, target: &str) -> Result<()> {
20 let task_path = find_task_path(board_dir, task_id)?;
21 let task = Task::from_file(&task_path)?;
22 let current = parse_task_state(&task.status)?;
23 let target = parse_task_state(target)?;
24
25 can_transition(current, target).map_err(anyhow::Error::msg)?;
26
27 update_task_frontmatter(&task_path, |mapping| {
28 set_status(mapping, target);
29 if target != TaskState::Blocked {
30 clear_blocked(mapping);
31 }
32 })?;
33 Ok(())
34}
35
36pub(crate) fn block_task_with_reason(board_dir: &Path, task_id: u32, reason: &str) -> Result<()> {
37 let task_path = find_task_path(board_dir, task_id)?;
38 update_task_frontmatter(&task_path, |mapping| {
39 set_status(mapping, TaskState::Blocked);
40 set_blocked_reason(mapping, Some(reason), Some(reason));
41 })?;
42 Ok(())
43}
44
45pub fn cmd_assign(
46 board_dir: &Path,
47 task_id: u32,
48 exec_owner: Option<&str>,
49 review_owner: Option<&str>,
50) -> Result<()> {
51 if exec_owner.is_none() && review_owner.is_none() {
52 bail!("at least one owner must be provided");
53 }
54
55 assign_task_owners(board_dir, task_id, exec_owner, review_owner)?;
56
57 println!("Task #{task_id} ownership updated.");
58 Ok(())
59}
60
61pub(crate) fn assign_task_owners(
62 board_dir: &Path,
63 task_id: u32,
64 exec_owner: Option<&str>,
65 review_owner: Option<&str>,
66) -> Result<()> {
67 if exec_owner.is_none() && review_owner.is_none() {
68 bail!("at least one owner must be provided");
69 }
70
71 let task_path = find_task_path(board_dir, task_id)?;
72 update_task_frontmatter(&task_path, |mapping| {
73 if let Some(owner) = exec_owner {
74 set_optional_string(mapping, "claimed_by", normalize_optional(owner));
75 if normalize_optional(owner).is_some() {
76 let now = Utc::now();
77 set_optional_string(mapping, "claimed_at", Some(&now.to_rfc3339()));
78 }
79 }
80 if let Some(owner) = review_owner {
81 set_optional_string(mapping, "review_owner", normalize_optional(owner));
82 }
83 })?;
84 Ok(())
85}
86
87pub(crate) fn unclaim_task(board_dir: &Path, task_id: u32) -> Result<()> {
89 let task_path = find_task_path(board_dir, task_id)?;
90 update_task_frontmatter(&task_path, |mapping| {
91 set_optional_string(mapping, "claimed_by", None);
92 set_optional_string(mapping, "review_owner", None);
93 set_optional_string(mapping, "claimed_at", None);
94 set_optional_u64(mapping, "claim_ttl_secs", None);
95 set_optional_string(mapping, "claim_expires_at", None);
96 set_optional_string(mapping, "last_progress_at", None);
97 set_optional_string(mapping, "claim_warning_sent_at", None);
98 set_optional_u32(mapping, "claim_extensions", None);
99 set_optional_u64(mapping, "last_output_bytes", None);
100 })?;
101 Ok(())
102}
103
104pub(crate) fn release_engineer_claim(board_dir: &Path, task_id: u32) -> Result<()> {
106 let task_path = find_task_path(board_dir, task_id)?;
107 update_task_frontmatter(&task_path, |mapping| {
108 set_optional_string(mapping, "claimed_by", None);
109 set_optional_string(mapping, "claimed_at", None);
110 set_optional_u64(mapping, "claim_ttl_secs", None);
111 set_optional_string(mapping, "claim_expires_at", None);
112 set_optional_string(mapping, "last_progress_at", None);
113 set_optional_string(mapping, "claim_warning_sent_at", None);
114 set_optional_u32(mapping, "claim_extensions", None);
115 set_optional_u64(mapping, "last_output_bytes", None);
116 })?;
117 Ok(())
118}
119
120pub(crate) fn initialize_task_claim(
121 board_dir: &Path,
122 task_id: u32,
123 ttl_secs: u64,
124 now: DateTime<Utc>,
125 output_bytes: u64,
126) -> Result<()> {
127 let task_path = find_task_path(board_dir, task_id)?;
128 let expires_at = now + ChronoDuration::seconds(ttl_secs as i64);
129 update_task_frontmatter(&task_path, |mapping| {
130 set_optional_string(mapping, "claimed_at", Some(&now.to_rfc3339()));
131 set_optional_u64(mapping, "claim_ttl_secs", Some(ttl_secs));
132 set_optional_string(mapping, "claim_expires_at", Some(&expires_at.to_rfc3339()));
133 set_optional_string(mapping, "last_progress_at", Some(&now.to_rfc3339()));
134 set_optional_string(mapping, "claim_warning_sent_at", None);
135 set_optional_u32(mapping, "claim_extensions", Some(0));
136 set_optional_u64(mapping, "last_output_bytes", Some(output_bytes));
137 })?;
138 Ok(())
139}
140
141pub(crate) fn refresh_task_claim_progress(
142 board_dir: &Path,
143 task_id: u32,
144 ttl_secs: u64,
145 now: DateTime<Utc>,
146 output_bytes: u64,
147 extensions: u32,
148) -> Result<()> {
149 let task_path = find_task_path(board_dir, task_id)?;
150 let expires_at = now + ChronoDuration::seconds(ttl_secs as i64);
151 update_task_frontmatter(&task_path, |mapping| {
152 set_optional_u64(mapping, "claim_ttl_secs", Some(ttl_secs));
153 set_optional_string(mapping, "claim_expires_at", Some(&expires_at.to_rfc3339()));
154 set_optional_string(mapping, "last_progress_at", Some(&now.to_rfc3339()));
155 set_optional_string(mapping, "claim_warning_sent_at", None);
156 set_optional_u32(mapping, "claim_extensions", Some(extensions));
157 set_optional_u64(mapping, "last_output_bytes", Some(output_bytes));
158 })?;
159 Ok(())
160}
161
162pub(crate) fn mark_task_claim_warning(
163 board_dir: &Path,
164 task_id: u32,
165 now: DateTime<Utc>,
166) -> Result<()> {
167 let task_path = find_task_path(board_dir, task_id)?;
168 update_task_frontmatter(&task_path, |mapping| {
169 set_optional_string(mapping, "claim_warning_sent_at", Some(&now.to_rfc3339()));
170 })?;
171 Ok(())
172}
173
174pub(crate) fn reclaim_task_claim(board_dir: &Path, task_id: u32, next_action: &str) -> Result<()> {
175 let task_path = find_task_path(board_dir, task_id)?;
176 update_task_frontmatter(&task_path, |mapping| {
177 set_optional_string(mapping, "claimed_by", None);
178 set_optional_string(mapping, "review_owner", None);
179 set_optional_string(mapping, "claimed_at", None);
180 set_optional_u64(mapping, "claim_ttl_secs", None);
181 set_optional_string(mapping, "claim_expires_at", None);
182 set_optional_string(mapping, "last_progress_at", None);
183 set_optional_string(mapping, "claim_warning_sent_at", None);
184 set_optional_u32(mapping, "claim_extensions", None);
185 set_optional_u64(mapping, "last_output_bytes", None);
186 set_optional_string(mapping, "next_action", Some(next_action));
187 set_status(mapping, TaskState::Todo);
188 })?;
189 Ok(())
190}
191
192pub(crate) fn append_task_dependencies(
193 board_dir: &Path,
194 task_id: u32,
195 dependency_ids: &[u32],
196) -> Result<Vec<u32>> {
197 let task_path = find_task_path(board_dir, task_id)?;
198 let mut merged = Vec::new();
199 update_task_frontmatter(&task_path, |mapping| {
200 let key = yaml_key("depends_on");
201 let mut deps = BTreeSet::new();
202 if let Some(Value::Sequence(existing)) = mapping.get(&key) {
203 for value in existing {
204 if let Some(dep_id) = value.as_u64() {
205 deps.insert(dep_id as u32);
206 }
207 }
208 }
209 deps.extend(dependency_ids.iter().copied());
210 merged = deps.iter().copied().collect();
211 if merged.is_empty() {
212 mapping.remove(key);
213 } else {
214 mapping.insert(
215 key,
216 Value::Sequence(
217 merged
218 .iter()
219 .map(|dep_id| Value::Number((*dep_id as u64).into()))
220 .collect(),
221 ),
222 );
223 }
224 })?;
225 Ok(merged)
226}
227
228pub fn cmd_review(
229 board_dir: &Path,
230 task_id: u32,
231 disposition: &str,
232 feedback: Option<&str>,
233) -> Result<()> {
234 let task_path = find_task_path(board_dir, task_id)?;
235 let task = Task::from_file(&task_path)?;
236 let current = parse_task_state(&task.status)?;
237 let disposition = parse_review_disposition(disposition)?;
238 let target = match disposition {
239 ReviewDisposition::Approved => TaskState::Done,
240 ReviewDisposition::ChangesRequested => TaskState::InProgress,
241 ReviewDisposition::Rejected => TaskState::Archived,
242 };
243
244 can_transition(current, target).map_err(anyhow::Error::msg)?;
245
246 update_task_frontmatter(&task_path, |mapping| {
247 set_status(mapping, target);
248 clear_blocked(mapping);
249 if let Some(text) = feedback {
250 set_optional_string(mapping, "review_feedback", Some(text));
251 }
252 })?;
253
254 let mut metadata = read_workflow_metadata(&task_path)?;
255 metadata.outcome = Some(review_disposition_name(disposition).to_string());
256 if disposition == ReviewDisposition::Approved {
257 metadata.review_blockers.clear();
258 }
259 write_workflow_metadata(&task_path, &metadata)?;
260
261 if disposition == ReviewDisposition::ChangesRequested {
262 if let Some(text) = feedback {
263 if let Some(engineer) = task.claimed_by.as_deref() {
266 if let Some(project_root) = board_dir
267 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent())
270 {
272 let inbox_root = super::inbox::inboxes_root(project_root);
273 if let Ok(()) = queue_review_feedback(&inbox_root, engineer, task_id, text) {
274 println!("Review feedback delivered to {engineer}'s inbox.");
275 }
276 }
277 }
278 }
279 }
280
281 println!(
282 "Task #{task_id} review recorded as {}.",
283 review_disposition_name(disposition)
284 );
285 Ok(())
286}
287
288fn queue_review_feedback(
289 inbox_root: &Path,
290 engineer: &str,
291 task_id: u32,
292 feedback: &str,
293) -> Result<()> {
294 use super::inbox;
295 let message = format!("Review feedback for task #{task_id}: {feedback}");
296 let msg = inbox::InboxMessage::new_send("reviewer", engineer, &message);
297 inbox::deliver_to_inbox(inbox_root, &msg)?;
298 Ok(())
299}
300
301pub fn cmd_review_structured(
309 board_dir: &Path,
310 task_id: u32,
311 disposition: &str,
312 feedback: Option<&str>,
313 reviewer: &str,
314) -> Result<()> {
315 let task_path = find_task_path(board_dir, task_id)?;
316 let task = Task::from_file(&task_path)?;
317 let current = parse_task_state(&task.status)?;
318
319 let (target_state, disposition_str) = match disposition {
320 "approve" => (TaskState::Done, "approved"),
321 "request-changes" | "request_changes" => (TaskState::InProgress, "changes_requested"),
322 "reject" => (TaskState::Blocked, "rejected"),
323 other => bail!("unknown review disposition: {other}"),
324 };
325
326 can_transition(current, target_state).map_err(anyhow::Error::msg)?;
327
328 let now = chrono::Utc::now().to_rfc3339();
329 let default_reject_reason = format!("rejected by {reviewer}");
330
331 update_task_frontmatter(&task_path, |mapping| {
332 set_status(mapping, target_state);
333 set_optional_string(mapping, "review_disposition", Some(disposition_str));
334 set_optional_string(mapping, "reviewed_by", Some(reviewer));
335 set_optional_string(mapping, "reviewed_at", Some(&now));
336 if let Some(text) = feedback {
337 set_optional_string(mapping, "review_feedback", Some(text));
338 }
339 if target_state == TaskState::Blocked {
340 let reason = feedback.unwrap_or(&default_reject_reason);
341 set_blocked_reason(mapping, Some(reason), Some(reason));
342 } else {
343 clear_blocked(mapping);
344 }
345 })?;
346
347 let mut metadata = read_workflow_metadata(&task_path)?;
349 metadata.outcome = Some(disposition_str.to_string());
350 if disposition == "approve" {
351 metadata.review_blockers.clear();
352 }
353 write_workflow_metadata(&task_path, &metadata)?;
354
355 if disposition == "request-changes" || disposition == "request_changes" {
357 if let Some(text) = feedback {
358 if let Some(engineer) = task.claimed_by.as_deref() {
359 if let Some(project_root) = board_dir
360 .parent()
361 .and_then(|p| p.parent())
362 .and_then(|p| p.parent())
363 {
364 let inbox_root = super::inbox::inboxes_root(project_root);
365 if let Ok(()) = queue_review_feedback(&inbox_root, engineer, task_id, text) {
366 println!("Review feedback delivered to {engineer}'s inbox.");
367 }
368 }
369 }
370 }
371 }
372
373 println!("Task #{task_id} review recorded as {disposition_str} by {reviewer}.");
374 Ok(())
375}
376
377pub fn cmd_update(board_dir: &Path, task_id: u32, fields: HashMap<String, String>) -> Result<()> {
378 if fields.is_empty() {
379 bail!("no workflow fields provided");
380 }
381
382 let task_path = find_task_path(board_dir, task_id)?;
383 let mut metadata = read_workflow_metadata(&task_path)?;
384 let mut metadata_changed = false;
385
386 if let Some(branch) = fields.get("branch") {
387 metadata.branch = normalize_optional(branch).map(str::to_string);
388 metadata_changed = true;
389 }
390 if let Some(commit) = fields.get("commit") {
391 metadata.commit = normalize_optional(commit).map(str::to_string);
392 metadata_changed = true;
393 }
394 if metadata_changed {
395 write_workflow_metadata(&task_path, &metadata)?;
396 }
397
398 let blocked_on = fields.get("blocked_on").cloned();
399 let block_reason = fields.get("block_reason").cloned();
400 let should_clear_blocked = fields.contains_key("clear_blocked");
401 if blocked_on.is_some() || block_reason.is_some() || should_clear_blocked {
402 update_task_frontmatter(&task_path, |mapping| {
403 if should_clear_blocked {
404 clear_blocked(mapping);
405 }
406 let normalized_reason = block_reason
407 .as_deref()
408 .and_then(normalize_optional)
409 .or_else(|| blocked_on.as_deref().and_then(normalize_optional));
410 let normalized_blocked_on = blocked_on.as_deref().and_then(normalize_optional);
411 if normalized_reason.is_some() || normalized_blocked_on.is_some() {
412 set_blocked_reason(mapping, normalized_reason, normalized_blocked_on);
413 }
414 })?;
415 }
416
417 println!("Task #{task_id} metadata updated.");
418 Ok(())
419}
420
421pub fn cmd_schedule(
422 board_dir: &Path,
423 task_id: u32,
424 at: Option<&str>,
425 cron_expr: Option<&str>,
426 clear: bool,
427) -> Result<()> {
428 if !clear && at.is_none() && cron_expr.is_none() {
429 bail!("at least one of --at, --cron, or --clear is required");
430 }
431
432 if let Some(ts) = at {
434 chrono::DateTime::parse_from_rfc3339(ts)
435 .with_context(|| format!("invalid RFC3339 timestamp: {ts}"))?;
436 }
437
438 if let Some(expr) = cron_expr {
441 use std::str::FromStr;
442 let normalized = normalize_cron(expr);
443 cron::Schedule::from_str(&normalized)
444 .map_err(|e| anyhow::anyhow!("invalid cron expression: {e}"))?;
445 }
446
447 let task_path = find_task_path(board_dir, task_id)?;
448 update_task_frontmatter(&task_path, |mapping| {
449 if clear {
450 mapping.remove(yaml_key("scheduled_for"));
451 mapping.remove(yaml_key("cron_schedule"));
452 } else {
453 if let Some(ts) = at {
454 mapping.insert(yaml_key("scheduled_for"), Value::String(ts.to_string()));
455 }
456 if let Some(expr) = cron_expr {
457 mapping.insert(yaml_key("cron_schedule"), Value::String(expr.to_string()));
458 }
459 }
460 })?;
461
462 if clear {
463 println!("Task #{task_id} schedule cleared.");
464 } else {
465 let mut parts = Vec::new();
466 if let Some(ts) = at {
467 parts.push(format!("scheduled_for={ts}"));
468 }
469 if let Some(expr) = cron_expr {
470 parts.push(format!("cron_schedule={expr}"));
471 }
472 println!("Task #{task_id} schedule updated: {}", parts.join(", "));
473 }
474 Ok(())
475}
476
477pub fn cmd_auto_merge(task_id: u32, enabled: bool, project_root: &Path) -> Result<()> {
478 super::auto_merge::save_override(project_root, task_id, enabled)?;
479 let action = if enabled { "enabled" } else { "disabled" };
480 println!(
481 "Auto-merge {action} for task #{task_id}. The daemon will pick this up on its next completion evaluation."
482 );
483 Ok(())
484}
485
486pub(crate) fn find_task_path(board_dir: &Path, task_id: u32) -> Result<PathBuf> {
487 crate::task::find_task_path_by_id(&board_dir.join("tasks"), task_id)
488}
489
490fn parse_task_state(value: &str) -> Result<TaskState> {
491 match value.trim().replace('-', "_").as_str() {
492 "backlog" => Ok(TaskState::Backlog),
493 "todo" => Ok(TaskState::Todo),
494 "in_progress" => Ok(TaskState::InProgress),
495 "review" => Ok(TaskState::Review),
496 "blocked" => Ok(TaskState::Blocked),
497 "done" => Ok(TaskState::Done),
498 "archived" => Ok(TaskState::Archived),
499 other => bail!("unknown task state `{other}`"),
500 }
501}
502
503fn parse_review_disposition(value: &str) -> Result<ReviewDisposition> {
504 match value.trim().replace('-', "_").as_str() {
505 "approved" => Ok(ReviewDisposition::Approved),
506 "changes_requested" => Ok(ReviewDisposition::ChangesRequested),
507 "rejected" => Ok(ReviewDisposition::Rejected),
508 other => bail!("unknown review disposition `{other}`"),
509 }
510}
511
512fn state_name(state: TaskState) -> &'static str {
513 match state {
514 TaskState::Backlog => "backlog",
515 TaskState::Todo => "todo",
516 TaskState::InProgress => "in-progress",
517 TaskState::Review => "review",
518 TaskState::Blocked => "blocked",
519 TaskState::Done => "done",
520 TaskState::Archived => "archived",
521 }
522}
523
524fn review_disposition_name(disposition: ReviewDisposition) -> &'static str {
525 match disposition {
526 ReviewDisposition::Approved => "approved",
527 ReviewDisposition::ChangesRequested => "changes_requested",
528 ReviewDisposition::Rejected => "rejected",
529 }
530}
531
532pub(crate) fn update_task_frontmatter<F>(task_path: &Path, mutator: F) -> Result<()>
533where
534 F: FnOnce(&mut Mapping),
535{
536 let content = std::fs::read_to_string(task_path)
537 .with_context(|| format!("failed to read {}", task_path.display()))?;
538 let (frontmatter, body) = split_task_frontmatter(&content)?;
539 let mut mapping: Mapping =
540 serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
541 mutator(&mut mapping);
542
543 let mut rendered =
544 serde_yaml::to_string(&mapping).context("failed to serialize task frontmatter")?;
545 if let Some(stripped) = rendered.strip_prefix("---\n") {
546 rendered = stripped.to_string();
547 }
548
549 let mut updated = String::from("---\n");
550 updated.push_str(&rendered);
551 if !updated.ends_with('\n') {
552 updated.push('\n');
553 }
554 updated.push_str("---\n");
555 updated.push_str(body);
556
557 std::fs::write(task_path, updated)
558 .with_context(|| format!("failed to write {}", task_path.display()))?;
559 Ok(())
560}
561
562fn split_task_frontmatter(content: &str) -> Result<(&str, &str)> {
563 let trimmed = content.trim_start();
564 if !trimmed.starts_with("---") {
565 bail!("task file missing YAML frontmatter (no opening ---)");
566 }
567
568 let after_open = &trimmed[3..];
569 let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
570 let close_pos = after_open
571 .find("\n---")
572 .context("task file missing closing --- for frontmatter")?;
573
574 let frontmatter = &after_open[..close_pos];
575 let body = &after_open[close_pos + 4..];
576 Ok((frontmatter, body.strip_prefix('\n').unwrap_or(body)))
577}
578
579fn set_status(mapping: &mut Mapping, state: TaskState) {
580 mapping.insert(
581 yaml_key("status"),
582 Value::String(state_name(state).to_string()),
583 );
584}
585
586fn clear_blocked(mapping: &mut Mapping) {
587 mapping.remove(yaml_key("blocked"));
588 mapping.remove(yaml_key("block_reason"));
589 mapping.remove(yaml_key("blocked_on"));
590}
591
592fn set_blocked_reason(mapping: &mut Mapping, reason: Option<&str>, blocked_on: Option<&str>) {
593 if reason.is_none() && blocked_on.is_none() {
594 clear_blocked(mapping);
595 return;
596 }
597
598 mapping.insert(yaml_key("blocked"), Value::Bool(true));
599 set_optional_string(mapping, "block_reason", reason);
600 set_optional_string(mapping, "blocked_on", blocked_on.or(reason));
601}
602
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub(crate) struct TaskFrontmatterRepair {
605 pub(crate) task_id: Option<u32>,
606 pub(crate) status: Option<String>,
607 pub(crate) reason: Option<String>,
608 pub(crate) repaired_fields: Vec<String>,
609 pub(crate) path: PathBuf,
610}
611
612pub(crate) fn normalize_blocked_frontmatter(
613 task_path: &Path,
614) -> Result<Option<TaskFrontmatterRepair>> {
615 let Some(repair) = crate::task::repair_task_frontmatter_compat(task_path)? else {
616 return Ok(None);
617 };
618 let task = Task::from_file(task_path)?;
619 let reason = if repair
620 .repaired_fields
621 .iter()
622 .any(|field| matches!(field.as_str(), "blocked" | "block_reason" | "blocked_on"))
623 {
624 repair
625 .blocked_reason
626 .clone()
627 .or_else(|| Some("normalized blocked frontmatter".to_string()))
628 } else if repair.repaired_fields.is_empty() {
629 None
630 } else {
631 Some(format!(
632 "normalized timestamp fields: {}",
633 repair.repaired_fields.join(", ")
634 ))
635 };
636 Ok(Some(TaskFrontmatterRepair {
637 task_id: Some(task.id),
638 status: Some(task.status),
639 reason,
640 repaired_fields: repair.repaired_fields,
641 path: task_path.to_path_buf(),
642 }))
643}
644
645pub(crate) fn repair_board_frontmatter_compat(
646 board_dir: &Path,
647) -> Result<Vec<TaskFrontmatterRepair>> {
648 let tasks_dir = board_dir.join("tasks");
649 let Ok(entries) = std::fs::read_dir(&tasks_dir) else {
650 return Ok(Vec::new());
651 };
652
653 let mut repairs = Vec::new();
654 for entry in entries.flatten() {
655 let path = entry.path();
656 if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
657 continue;
658 }
659 if let Some(repair) = normalize_blocked_frontmatter(&path)? {
660 repairs.push(repair);
661 }
662 }
663
664 Ok(repairs)
665}
666
667pub(crate) fn set_optional_string(mapping: &mut Mapping, key: &str, value: Option<&str>) {
668 let key = yaml_key(key);
669 match value {
670 Some(value) => {
671 mapping.insert(key, Value::String(value.to_string()));
672 }
673 None => {
674 mapping.remove(key);
675 }
676 }
677}
678
679pub(crate) fn set_optional_u64(mapping: &mut Mapping, key: &str, value: Option<u64>) {
680 let key = yaml_key(key);
681 match value {
682 Some(value) => {
683 mapping.insert(key, Value::Number(value.into()));
684 }
685 None => {
686 mapping.remove(key);
687 }
688 }
689}
690
691pub(crate) fn set_optional_u32(mapping: &mut Mapping, key: &str, value: Option<u32>) {
692 set_optional_u64(mapping, key, value.map(u64::from));
693}
694
695pub(crate) fn yaml_key(name: &str) -> Value {
696 Value::String(name.to_string())
697}
698
699fn normalize_cron(expr: &str) -> String {
703 let fields: Vec<&str> = expr.split_whitespace().collect();
704 if fields.len() == 5 {
705 format!("0 {expr}")
706 } else {
707 expr.to_string()
708 }
709}
710
711fn normalize_optional(value: &str) -> Option<&str> {
712 let trimmed = value.trim();
713 if trimmed.is_empty() {
714 None
715 } else {
716 Some(trimmed)
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 fn write_task_file(dir: &Path, id: u32, status: &str) -> PathBuf {
725 let tasks_dir = dir.join("tasks");
726 std::fs::create_dir_all(&tasks_dir).unwrap();
727 let path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
728 std::fs::write(
729 &path,
730 format!(
731 "---\nid: {id}\ntitle: Task {id}\nstatus: {status}\npriority: high\nclass: standard\n---\n\nTask body.\n"
732 ),
733 )
734 .unwrap();
735 path
736 }
737
738 #[test]
739 fn transition_updates_task_status() {
740 let tmp = tempfile::tempdir().unwrap();
741 let board_dir = tmp.path();
742 let task_path = write_task_file(board_dir, 7, "todo");
743
744 cmd_transition(board_dir, 7, "in-progress").unwrap();
745
746 let task = Task::from_file(&task_path).unwrap();
747 assert_eq!(task.status, "in-progress");
748 }
749
750 #[test]
751 fn illegal_transition_returns_error() {
752 let tmp = tempfile::tempdir().unwrap();
753 let board_dir = tmp.path();
754 write_task_file(board_dir, 8, "backlog");
755
756 let error = cmd_transition(board_dir, 8, "done")
757 .unwrap_err()
758 .to_string();
759 assert!(error.contains("illegal task state transition"));
760 }
761
762 #[test]
763 fn assign_updates_execution_and_review_owners() {
764 let tmp = tempfile::tempdir().unwrap();
765 let board_dir = tmp.path();
766 let task_path = write_task_file(board_dir, 9, "todo");
767
768 cmd_assign(board_dir, 9, Some("eng-1-2"), Some("manager-1")).unwrap();
769
770 let task = Task::from_file(&task_path).unwrap();
771 assert_eq!(task.claimed_by.as_deref(), Some("eng-1-2"));
772 assert_eq!(task.review_owner.as_deref(), Some("manager-1"));
773 }
774
775 #[test]
776 fn review_updates_status_and_outcome() {
777 let tmp = tempfile::tempdir().unwrap();
778 let board_dir = tmp.path();
779 let task_path = write_task_file(board_dir, 10, "review");
780
781 cmd_review(board_dir, 10, "approved", None).unwrap();
782
783 let task = Task::from_file(&task_path).unwrap();
784 assert_eq!(task.status, "done");
785 let metadata = read_workflow_metadata(&task_path).unwrap();
786 assert_eq!(metadata.outcome.as_deref(), Some("approved"));
787 }
788
789 #[test]
790 fn update_writes_board_metadata_and_block_reason() {
791 let tmp = tempfile::tempdir().unwrap();
792 let board_dir = tmp.path();
793 let task_path = write_task_file(board_dir, 11, "blocked");
794
795 let fields = HashMap::from([
796 ("branch".to_string(), "eng-1-2/task-11".to_string()),
797 ("commit".to_string(), "abc1234".to_string()),
798 ("blocked_on".to_string(), "waiting for review".to_string()),
799 ]);
800 cmd_update(board_dir, 11, fields).unwrap();
801
802 let metadata = read_workflow_metadata(&task_path).unwrap();
803 assert_eq!(metadata.branch.as_deref(), Some("eng-1-2/task-11"));
804 assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
805
806 let task = Task::from_file(&task_path).unwrap();
807 assert_eq!(task.blocked.as_deref(), Some("waiting for review"));
808 assert_eq!(task.blocked_on.as_deref(), Some("waiting for review"));
809 let content = std::fs::read_to_string(&task_path).unwrap();
810 assert!(content.contains("blocked: true"));
811 assert!(content.contains("block_reason: waiting for review"));
812
813 cmd_update(
814 board_dir,
815 11,
816 HashMap::from([("clear_blocked".to_string(), "true".to_string())]),
817 )
818 .unwrap();
819
820 let task = Task::from_file(&task_path).unwrap();
821 assert!(task.blocked.is_none());
822 assert!(task.blocked_on.is_none());
823 }
824
825 #[test]
826 fn normalize_blocked_frontmatter_repairs_legacy_string_shape() {
827 let tmp = tempfile::tempdir().unwrap();
828 let board_dir = tmp.path();
829 let task_path = write_task_file(board_dir, 14, "blocked");
830 std::fs::write(
831 &task_path,
832 "---\nid: 14\ntitle: Task 14\nstatus: blocked\npriority: high\nblocked: legacy verification reason\nclass: standard\n---\n\nTask body.\n",
833 )
834 .unwrap();
835
836 let repair = normalize_blocked_frontmatter(&task_path).unwrap();
837
838 assert_eq!(repair.as_ref().and_then(|entry| entry.task_id), Some(14));
839 let content = std::fs::read_to_string(&task_path).unwrap();
840 assert!(content.contains("blocked: true"));
841 assert!(content.contains("block_reason: legacy verification reason"));
842 let task = Task::from_file(&task_path).unwrap();
843 assert_eq!(task.blocked.as_deref(), Some("legacy verification reason"));
844 }
845
846 #[test]
847 fn normalize_blocked_frontmatter_repairs_hidden_in_progress_task_without_changing_status() {
848 let tmp = tempfile::tempdir().unwrap();
849 let board_dir = tmp.path();
850 let task_path = write_task_file(board_dir, 15, "in-progress");
851 std::fs::write(
852 &task_path,
853 "---\nid: 15\ntitle: Task 15\nstatus: in-progress\npriority: high\nclaimed_by: eng-1-2\nblocked: waiting on reviewer\nclass: standard\n---\n\nTask body.\n",
854 )
855 .unwrap();
856
857 let repair = normalize_blocked_frontmatter(&task_path).unwrap();
858
859 assert_eq!(repair.as_ref().and_then(|entry| entry.task_id), Some(15));
860 assert_eq!(
861 repair.as_ref().and_then(|entry| entry.status.as_deref()),
862 Some("in-progress")
863 );
864 let content = std::fs::read_to_string(&task_path).unwrap();
865 assert!(content.contains("status: in-progress"));
866 assert!(content.contains("blocked: true"));
867 assert!(content.contains("block_reason: waiting on reviewer"));
868 assert!(content.contains("blocked_on: waiting on reviewer"));
869 }
870
871 #[test]
872 fn normalize_blocked_frontmatter_is_idempotent_after_first_repair() {
873 let tmp = tempfile::tempdir().unwrap();
874 let board_dir = tmp.path();
875 let task_path = write_task_file(board_dir, 16, "todo");
876 std::fs::write(
877 &task_path,
878 "---\nid: 16\ntitle: Task 16\nstatus: todo\npriority: high\nblocked: waiting on manager\nclass: standard\n---\n\nTask body.\n",
879 )
880 .unwrap();
881
882 let first = normalize_blocked_frontmatter(&task_path).unwrap();
883 let second = normalize_blocked_frontmatter(&task_path).unwrap();
884
885 assert!(first.is_some());
886 assert!(second.is_none());
887 }
888
889 #[test]
890 fn normalize_blocked_frontmatter_is_idempotent_on_canonical_blocked_status() {
891 let tmp = tempfile::tempdir().unwrap();
899 let board_dir = tmp.path();
900 let task_path = write_task_file(board_dir, 42, "todo");
901 std::fs::write(
902 &task_path,
903 "---\n\
904id: 42\n\
905title: Already canonical\n\
906status: blocked\n\
907priority: high\n\
908blocked: true\n\
909block_reason: 'verification escalation after 2 attempts: narration-only completion'\n\
910blocked_on: 'verification escalation after 2 attempts: narration-only completion'\n\
911class: standard\n\
912---\n\n\
913Body.\n",
914 )
915 .unwrap();
916
917 let first = normalize_blocked_frontmatter(&task_path).unwrap();
918 let second = normalize_blocked_frontmatter(&task_path).unwrap();
919 let third = normalize_blocked_frontmatter(&task_path).unwrap();
920
921 assert!(
922 first.is_none(),
923 "canonical blocked task must not trigger a repair on first call"
924 );
925 assert!(second.is_none());
926 assert!(third.is_none());
927 }
928
929 #[test]
930 fn repair_board_frontmatter_compat_repairs_legacy_timestamp_offsets_idempotently() {
931 let tmp = tempfile::tempdir().unwrap();
932 let board_dir = tmp.path();
933 let task_path = write_task_file(board_dir, 623, "review");
934 std::fs::write(
935 &task_path,
936 "---\nid: 623\ntitle: stale review\nstatus: review\npriority: high\ncreated: 2026-04-10T16:31:02.743151-04:00\nupdated: 2026-04-10T19:26:40-0400\nartifacts:\n - .batty/reports/verification/completion/task-623-eng-1-1-attempt-1.json\nreview_disposition: approved\nreviewed_by: architect\nreviewed_at: 2026-04-10T23:26:40+00:00\nclass: standard\n---\n\nTask body.\n",
937 )
938 .unwrap();
939
940 let first = repair_board_frontmatter_compat(board_dir).unwrap();
941 let second = repair_board_frontmatter_compat(board_dir).unwrap();
942
943 assert_eq!(first.len(), 1);
944 assert_eq!(first[0].task_id, Some(623));
945 assert_eq!(
946 first[0].reason.as_deref(),
947 Some("normalized timestamp fields: updated")
948 );
949 assert_eq!(first[0].repaired_fields, vec!["updated".to_string()]);
950 assert!(second.is_empty(), "timestamp repair must be idempotent");
951
952 let content = std::fs::read_to_string(&task_path).unwrap();
953 assert!(content.contains("updated: 2026-04-10T19:26:40-04:00"));
954 assert!(content.contains("reviewed_by: architect"));
955 assert!(content.contains("review_disposition: approved"));
956 assert!(
957 content.contains(
958 "- .batty/reports/verification/completion/task-623-eng-1-1-attempt-1.json"
959 )
960 );
961 assert!(content.ends_with("\n\nTask body.\n"));
962 }
963
964 #[test]
965 fn update_requires_at_least_one_field() {
966 let tmp = tempfile::tempdir().unwrap();
967 let board_dir = tmp.path();
968 write_task_file(board_dir, 12, "todo");
969
970 let error = cmd_update(board_dir, 12, HashMap::new())
971 .unwrap_err()
972 .to_string();
973 assert!(error.contains("no workflow fields provided"));
974 }
975
976 #[test]
977 fn task_commands_work_without_orchestrator_runtime() {
978 let tmp = tempfile::tempdir().unwrap();
979 let board_dir = tmp.path();
980 let task_path = write_task_file(board_dir, 13, "todo");
981
982 cmd_assign(board_dir, 13, Some("eng-1-2"), Some("manager-1")).unwrap();
983 cmd_transition(board_dir, 13, "in-progress").unwrap();
984 cmd_transition(board_dir, 13, "review").unwrap();
985 cmd_update(
986 board_dir,
987 13,
988 HashMap::from([
989 ("branch".to_string(), "eng-1-2/task-13".to_string()),
990 ("commit".to_string(), "deadbeef".to_string()),
991 ]),
992 )
993 .unwrap();
994 cmd_review(board_dir, 13, "approved", None).unwrap();
995
996 let task = Task::from_file(&task_path).unwrap();
997 let metadata = read_workflow_metadata(&task_path).unwrap();
998 assert_eq!(task.status, "done");
999 assert_eq!(task.claimed_by.as_deref(), Some("eng-1-2"));
1000 assert_eq!(task.review_owner.as_deref(), Some("manager-1"));
1001 assert_eq!(metadata.branch.as_deref(), Some("eng-1-2/task-13"));
1002 assert_eq!(metadata.commit.as_deref(), Some("deadbeef"));
1003 assert_eq!(metadata.outcome.as_deref(), Some("approved"));
1004 }
1005
1006 fn write_review_task_with_engineer(dir: &Path, id: u32, engineer: &str) -> PathBuf {
1007 let tasks_dir = dir.join("tasks");
1008 std::fs::create_dir_all(&tasks_dir).unwrap();
1009 let path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
1010 std::fs::write(
1011 &path,
1012 format!(
1013 "---\nid: {id}\ntitle: Task {id}\nstatus: review\npriority: high\nclass: standard\nclaimed_by: {engineer}\n---\n\nTask body.\n"
1014 ),
1015 )
1016 .unwrap();
1017 path
1018 }
1019
1020 #[test]
1021 fn review_feedback_stored_in_task() {
1022 let tmp = tempfile::tempdir().unwrap();
1023 let board_dir = tmp.path();
1024 let task_path = write_review_task_with_engineer(board_dir, 42, "eng-1-2");
1025
1026 cmd_review(
1027 board_dir,
1028 42,
1029 "changes_requested",
1030 Some("fix the error handling"),
1031 )
1032 .unwrap();
1033
1034 let content = std::fs::read_to_string(&task_path).unwrap();
1035 assert!(
1036 content.contains("fix the error handling"),
1037 "feedback should be stored in task frontmatter"
1038 );
1039 }
1040
1041 #[test]
1042 fn review_feedback_delivered_to_engineer() {
1043 let tmp = tempfile::tempdir().unwrap();
1044
1045 let project_root = tmp.path().join("project");
1047 let actual_board_dir = project_root
1048 .join(".batty")
1049 .join("team_config")
1050 .join("board");
1051 std::fs::create_dir_all(actual_board_dir.join("tasks")).unwrap();
1052
1053 let inbox_root = crate::team::inbox::inboxes_root(&project_root);
1055 crate::team::inbox::init_inbox(&inbox_root, "eng-1-2").unwrap();
1056
1057 let task_path = actual_board_dir.join("tasks").join("042-task-42.md");
1059 std::fs::write(
1060 &task_path,
1061 "---\nid: 42\ntitle: Task 42\nstatus: review\npriority: high\nclass: standard\nclaimed_by: eng-1-2\n---\n\nTask body.\n",
1062 )
1063 .unwrap();
1064
1065 cmd_review(
1066 &actual_board_dir,
1067 42,
1068 "changes_requested",
1069 Some("fix the error handling"),
1070 )
1071 .unwrap();
1072
1073 let pending = crate::team::inbox::pending_messages(&inbox_root, "eng-1-2").unwrap();
1074 assert_eq!(pending.len(), 1);
1075 assert!(
1076 pending[0].body.contains("fix the error handling"),
1077 "feedback message should be delivered to engineer inbox"
1078 );
1079 assert!(pending[0].body.contains("#42"));
1080 }
1081
1082 #[test]
1083 fn schedule_task_sets_scheduled_for() {
1084 let tmp = tempfile::tempdir().unwrap();
1085 let board_dir = tmp.path();
1086 let task_path = write_task_file(board_dir, 60, "todo");
1087
1088 cmd_schedule(
1089 board_dir,
1090 60,
1091 Some("2026-03-25T09:00:00-04:00"),
1092 None,
1093 false,
1094 )
1095 .unwrap();
1096
1097 let task = Task::from_file(&task_path).unwrap();
1098 assert_eq!(
1099 task.scheduled_for.as_deref(),
1100 Some("2026-03-25T09:00:00-04:00")
1101 );
1102 assert!(task.cron_schedule.is_none());
1103 }
1104
1105 #[test]
1106 fn schedule_task_sets_cron_schedule() {
1107 let tmp = tempfile::tempdir().unwrap();
1108 let board_dir = tmp.path();
1109 let task_path = write_task_file(board_dir, 61, "todo");
1110
1111 cmd_schedule(board_dir, 61, None, Some("0 9 * * *"), false).unwrap();
1112
1113 let task = Task::from_file(&task_path).unwrap();
1114 assert!(task.scheduled_for.is_none());
1115 assert_eq!(task.cron_schedule.as_deref(), Some("0 9 * * *"));
1116 }
1117
1118 #[test]
1119 fn schedule_task_clear_removes_fields() {
1120 let tmp = tempfile::tempdir().unwrap();
1121 let board_dir = tmp.path();
1122 let task_path = write_task_file(board_dir, 62, "todo");
1123
1124 cmd_schedule(
1126 board_dir,
1127 62,
1128 Some("2026-04-01T00:00:00Z"),
1129 Some("0 9 * * 1"),
1130 false,
1131 )
1132 .unwrap();
1133 let task = Task::from_file(&task_path).unwrap();
1134 assert!(task.scheduled_for.is_some());
1135 assert!(task.cron_schedule.is_some());
1136
1137 cmd_schedule(board_dir, 62, None, None, true).unwrap();
1139 let task = Task::from_file(&task_path).unwrap();
1140 assert!(task.scheduled_for.is_none());
1141 assert!(task.cron_schedule.is_none());
1142 }
1143
1144 #[test]
1145 fn schedule_task_sets_both() {
1146 let tmp = tempfile::tempdir().unwrap();
1147 let board_dir = tmp.path();
1148 let task_path = write_task_file(board_dir, 63, "todo");
1149
1150 cmd_schedule(
1151 board_dir,
1152 63,
1153 Some("2026-04-01T00:00:00Z"),
1154 Some("0 9 * * 1"),
1155 false,
1156 )
1157 .unwrap();
1158
1159 let task = Task::from_file(&task_path).unwrap();
1160 assert_eq!(task.scheduled_for.as_deref(), Some("2026-04-01T00:00:00Z"));
1161 assert_eq!(task.cron_schedule.as_deref(), Some("0 9 * * 1"));
1162 }
1163
1164 #[test]
1165 fn schedule_rejects_invalid_timestamp() {
1166 let tmp = tempfile::tempdir().unwrap();
1167 let board_dir = tmp.path();
1168 write_task_file(board_dir, 64, "todo");
1169
1170 let err = cmd_schedule(board_dir, 64, Some("not-a-date"), None, false)
1171 .unwrap_err()
1172 .to_string();
1173 assert!(err.contains("invalid RFC3339 timestamp"));
1174 }
1175
1176 #[test]
1177 fn schedule_rejects_invalid_cron() {
1178 let tmp = tempfile::tempdir().unwrap();
1179 let board_dir = tmp.path();
1180 write_task_file(board_dir, 65, "todo");
1181
1182 let err = cmd_schedule(board_dir, 65, None, Some("not-a-cron"), false)
1183 .unwrap_err()
1184 .to_string();
1185 assert!(err.contains("invalid cron expression"));
1186 }
1187
1188 #[test]
1189 fn schedule_requires_at_least_one_flag() {
1190 let tmp = tempfile::tempdir().unwrap();
1191 let board_dir = tmp.path();
1192 write_task_file(board_dir, 66, "todo");
1193
1194 let err = cmd_schedule(board_dir, 66, None, None, false)
1195 .unwrap_err()
1196 .to_string();
1197 assert!(err.contains("at least one of --at, --cron, or --clear"));
1198 }
1199
1200 #[test]
1203 fn structured_review_approve_stores_frontmatter_and_moves_to_done() {
1204 let tmp = tempfile::tempdir().unwrap();
1205 let board_dir = tmp.path();
1206 let task_path = write_task_file(board_dir, 70, "review");
1207
1208 cmd_review_structured(board_dir, 70, "approve", None, "manager-1").unwrap();
1209
1210 let task = Task::from_file(&task_path).unwrap();
1211 assert_eq!(task.status, "done");
1212
1213 let content = std::fs::read_to_string(&task_path).unwrap();
1214 assert!(content.contains("review_disposition: approved"));
1215 assert!(content.contains("reviewed_by: manager-1"));
1216 assert!(content.contains("reviewed_at:"));
1217
1218 let metadata = read_workflow_metadata(&task_path).unwrap();
1219 assert_eq!(metadata.outcome.as_deref(), Some("approved"));
1220 }
1221
1222 #[test]
1223 fn structured_review_request_changes_stores_feedback_and_moves_to_in_progress() {
1224 let tmp = tempfile::tempdir().unwrap();
1225 let board_dir = tmp.path();
1226 let task_path = write_task_file(board_dir, 71, "review");
1227
1228 cmd_review_structured(
1229 board_dir,
1230 71,
1231 "request-changes",
1232 Some("fix the error handling"),
1233 "manager-1",
1234 )
1235 .unwrap();
1236
1237 let task = Task::from_file(&task_path).unwrap();
1238 assert_eq!(task.status, "in-progress");
1239
1240 let content = std::fs::read_to_string(&task_path).unwrap();
1241 assert!(content.contains("review_disposition: changes_requested"));
1242 assert!(content.contains("review_feedback: fix the error handling"));
1243 assert!(content.contains("reviewed_by: manager-1"));
1244 assert!(content.contains("reviewed_at:"));
1245 }
1246
1247 #[test]
1248 fn structured_review_reject_moves_to_blocked_with_reason() {
1249 let tmp = tempfile::tempdir().unwrap();
1250 let board_dir = tmp.path();
1251 let task_path = write_task_file(board_dir, 72, "review");
1252
1253 cmd_review_structured(
1254 board_dir,
1255 72,
1256 "reject",
1257 Some("does not meet requirements"),
1258 "manager-1",
1259 )
1260 .unwrap();
1261
1262 let task = Task::from_file(&task_path).unwrap();
1263 assert_eq!(task.status, "blocked");
1264
1265 let content = std::fs::read_to_string(&task_path).unwrap();
1266 assert!(content.contains("review_disposition: rejected"));
1267 assert!(content.contains("review_feedback: does not meet requirements"));
1268 assert!(content.contains("reviewed_by: manager-1"));
1269 assert!(content.contains("blocked_on: does not meet requirements"));
1270 }
1271
1272 #[test]
1273 fn structured_review_reject_without_feedback_uses_default_reason() {
1274 let tmp = tempfile::tempdir().unwrap();
1275 let board_dir = tmp.path();
1276 let task_path = write_task_file(board_dir, 73, "review");
1277
1278 cmd_review_structured(board_dir, 73, "reject", None, "manager-1").unwrap();
1279
1280 let task = Task::from_file(&task_path).unwrap();
1281 assert_eq!(task.status, "blocked");
1282
1283 let content = std::fs::read_to_string(&task_path).unwrap();
1284 assert!(content.contains("blocked_on: rejected by manager-1"));
1285 }
1286
1287 #[test]
1288 fn structured_review_rejects_non_review_state() {
1289 let tmp = tempfile::tempdir().unwrap();
1290 let board_dir = tmp.path();
1291 write_task_file(board_dir, 74, "in-progress");
1292
1293 let err = cmd_review_structured(board_dir, 74, "approve", None, "manager-1")
1294 .unwrap_err()
1295 .to_string();
1296 assert!(err.contains("illegal task state transition"));
1297 }
1298
1299 #[test]
1300 fn structured_review_feedback_delivered_to_engineer_inbox() {
1301 let tmp = tempfile::tempdir().unwrap();
1302
1303 let project_root = tmp.path().join("project");
1305 let actual_board_dir = project_root
1306 .join(".batty")
1307 .join("team_config")
1308 .join("board");
1309 std::fs::create_dir_all(actual_board_dir.join("tasks")).unwrap();
1310
1311 let inbox_root = crate::team::inbox::inboxes_root(&project_root);
1313 crate::team::inbox::init_inbox(&inbox_root, "eng-1-2").unwrap();
1314
1315 let task_path = actual_board_dir.join("tasks").join("075-task-75.md");
1317 std::fs::write(
1318 &task_path,
1319 "---\nid: 75\ntitle: Task 75\nstatus: review\npriority: high\nclass: standard\nclaimed_by: eng-1-2\n---\n\nTask body.\n",
1320 )
1321 .unwrap();
1322
1323 cmd_review_structured(
1324 &actual_board_dir,
1325 75,
1326 "request-changes",
1327 Some("add more tests"),
1328 "manager-1",
1329 )
1330 .unwrap();
1331
1332 let pending = crate::team::inbox::pending_messages(&inbox_root, "eng-1-2").unwrap();
1333 assert_eq!(pending.len(), 1);
1334 assert!(pending[0].body.contains("add more tests"));
1335 assert!(pending[0].body.contains("#75"));
1336 }
1337}