Skip to main content

batty_cli/team/
completion.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5
6use crate::task::load_tasks_from_dir;
7
8use super::board::{WorkflowMetadata, read_workflow_metadata, write_workflow_metadata};
9use super::team_config_dir;
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct CompletionPacket {
13    pub task_id: u32,
14    pub branch: Option<String>,
15    pub worktree_path: Option<String>,
16    pub commit: Option<String>,
17    #[serde(default)]
18    pub changed_paths: Vec<String>,
19    pub tests_run: bool,
20    pub tests_passed: bool,
21    #[serde(default)]
22    pub artifacts: Vec<String>,
23    pub outcome: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CompletionValidation {
28    pub is_complete: bool,
29    pub missing_fields: Vec<String>,
30    pub warnings: Vec<String>,
31}
32
33pub fn parse_completion(text: &str) -> Result<CompletionPacket> {
34    let content = extract_packet_text(text).unwrap_or(text).trim();
35
36    serde_json::from_str(content)
37        .or_else(|_| serde_yaml::from_str(content))
38        .context("failed to parse completion packet as JSON or YAML")
39}
40
41pub fn validate_completion(packet: &CompletionPacket) -> CompletionValidation {
42    let mut missing_fields = Vec::new();
43    let mut warnings = Vec::new();
44
45    if packet.task_id == 0 {
46        missing_fields.push("task_id".to_string());
47    }
48    if packet.branch.as_deref().is_none_or(str::is_empty) {
49        missing_fields.push("branch".to_string());
50    }
51    if packet.commit.as_deref().is_none_or(str::is_empty) {
52        missing_fields.push("commit".to_string());
53    }
54    if !packet.tests_run {
55        missing_fields.push("tests_run".to_string());
56    }
57    if packet.worktree_path.as_deref().is_none_or(str::is_empty) {
58        warnings.push("worktree_path missing".to_string());
59    }
60    if !packet.tests_passed {
61        warnings.push("tests_passed is false".to_string());
62    }
63    if packet.outcome.trim().is_empty() {
64        warnings.push("outcome missing".to_string());
65    }
66
67    CompletionValidation {
68        is_complete: missing_fields.is_empty(),
69        missing_fields,
70        warnings,
71    }
72}
73
74pub fn apply_completion_to_metadata(packet: &CompletionPacket, metadata: &mut WorkflowMetadata) {
75    metadata.branch = packet.branch.clone();
76    metadata.worktree_path = packet.worktree_path.clone();
77    metadata.commit = packet.commit.clone();
78    metadata.changed_paths = packet.changed_paths.clone();
79    metadata.tests_run = Some(packet.tests_run);
80    metadata.tests_passed = Some(packet.tests_passed);
81    metadata.artifacts = packet.artifacts.clone();
82    metadata.outcome = Some(packet.outcome.clone());
83}
84
85pub(crate) fn ingest_completion_message(project_root: &Path, message: &str) -> Result<Option<u32>> {
86    if !message.contains("Completion Packet") {
87        return Ok(None);
88    }
89
90    let packet = parse_completion(message)?;
91    let validation = validate_completion(&packet);
92    let task_path = find_task_path(project_root, packet.task_id)?;
93    let mut metadata = read_workflow_metadata(&task_path)?;
94    apply_completion_to_metadata(&packet, &mut metadata);
95    metadata.review_blockers = validation.missing_fields;
96    write_workflow_metadata(&task_path, &metadata)?;
97    Ok(Some(packet.task_id))
98}
99
100fn extract_packet_text(text: &str) -> Option<&str> {
101    if let Some(start) = text.find("```") {
102        let after_fence = &text[start + 3..];
103        let inner_start = after_fence.find('\n').map(|i| i + 1).unwrap_or(0);
104        let inner = &after_fence[inner_start..];
105        if let Some(end) = inner.find("```") {
106            return Some(inner[..end].trim());
107        }
108    }
109
110    text.find("## Completion Packet")
111        .map(|idx| &text[idx + "## Completion Packet".len()..])
112        .map(str::trim)
113        .filter(|content| !content.is_empty())
114}
115
116fn find_task_path(project_root: &Path, task_id: u32) -> Result<PathBuf> {
117    let tasks_dir = team_config_dir(project_root).join("board").join("tasks");
118    let tasks = load_tasks_from_dir(&tasks_dir)
119        .with_context(|| format!("failed to load tasks from {}", tasks_dir.display()))?;
120    tasks
121        .into_iter()
122        .find(|task| task.id == task_id)
123        .map(|task| task.source_path)
124        .with_context(|| format!("task #{task_id} not found in {}", tasks_dir.display()))
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn parse_completion_parses_json() {
133        let packet = parse_completion(
134            r#"{"task_id":27,"branch":"eng-1-4/task-27","worktree_path":".batty/worktrees/eng-1-4","commit":"abc1234","changed_paths":["src/team/completion.rs"],"tests_run":true,"tests_passed":true,"artifacts":["docs/workflow.md"],"outcome":"ready_for_review"}"#,
135        )
136        .unwrap();
137
138        assert_eq!(packet.task_id, 27);
139        assert_eq!(packet.branch.as_deref(), Some("eng-1-4/task-27"));
140        assert!(packet.tests_run);
141        assert!(packet.tests_passed);
142    }
143
144    #[test]
145    fn parse_completion_parses_fenced_yaml_block() {
146        let packet = parse_completion(
147            r#"Done.
148
149## Completion Packet
150
151```yaml
152task_id: 27
153branch: eng-1-4/task-27
154worktree_path: .batty/worktrees/eng-1-4
155commit: abc1234
156changed_paths:
157  - src/team/completion.rs
158tests_run: true
159tests_passed: false
160artifacts:
161  - docs/workflow.md
162outcome: ready_for_review
163```"#,
164        )
165        .unwrap();
166
167        assert_eq!(packet.task_id, 27);
168        assert_eq!(packet.commit.as_deref(), Some("abc1234"));
169        assert_eq!(packet.artifacts, vec!["docs/workflow.md"]);
170        assert!(!packet.tests_passed);
171    }
172
173    #[test]
174    fn parse_completion_returns_error_for_malformed_packet() {
175        let error = parse_completion("{not valid").unwrap_err().to_string();
176        assert!(error.contains("failed to parse completion packet"));
177    }
178
179    #[test]
180    fn validate_completion_reports_complete_packet() {
181        let validation = validate_completion(&CompletionPacket {
182            task_id: 27,
183            branch: Some("eng-1-4/task-27".to_string()),
184            worktree_path: Some(".batty/worktrees/eng-1-4".to_string()),
185            commit: Some("abc1234".to_string()),
186            changed_paths: vec!["src/team/completion.rs".to_string()],
187            tests_run: true,
188            tests_passed: true,
189            artifacts: vec!["docs/workflow.md".to_string()],
190            outcome: "ready_for_review".to_string(),
191        });
192
193        assert!(validation.is_complete);
194        assert!(validation.missing_fields.is_empty());
195        assert!(validation.warnings.is_empty());
196    }
197
198    #[test]
199    fn validate_completion_reports_missing_required_fields() {
200        let validation = validate_completion(&CompletionPacket {
201            task_id: 0,
202            branch: None,
203            worktree_path: None,
204            commit: None,
205            changed_paths: Vec::new(),
206            tests_run: false,
207            tests_passed: false,
208            artifacts: Vec::new(),
209            outcome: String::new(),
210        });
211
212        assert!(!validation.is_complete);
213        assert_eq!(
214            validation.missing_fields,
215            vec!["task_id", "branch", "commit", "tests_run"]
216        );
217        assert!(
218            validation
219                .warnings
220                .contains(&"worktree_path missing".to_string())
221        );
222        assert!(
223            validation
224                .warnings
225                .contains(&"tests_passed is false".to_string())
226        );
227    }
228
229    #[test]
230    fn apply_completion_to_metadata_copies_fields() {
231        let packet = CompletionPacket {
232            task_id: 27,
233            branch: Some("eng-1-4/task-27".to_string()),
234            worktree_path: Some(".batty/worktrees/eng-1-4".to_string()),
235            commit: Some("abc1234".to_string()),
236            changed_paths: vec!["src/team/completion.rs".to_string()],
237            tests_run: true,
238            tests_passed: true,
239            artifacts: vec!["docs/workflow.md".to_string()],
240            outcome: "ready_for_review".to_string(),
241        };
242        let mut metadata = WorkflowMetadata::default();
243
244        apply_completion_to_metadata(&packet, &mut metadata);
245
246        assert_eq!(metadata.branch, packet.branch);
247        assert_eq!(metadata.worktree_path, packet.worktree_path);
248        assert_eq!(metadata.commit, packet.commit);
249        assert_eq!(metadata.changed_paths, packet.changed_paths);
250        assert_eq!(metadata.tests_run, Some(true));
251        assert_eq!(metadata.tests_passed, Some(true));
252        assert_eq!(metadata.artifacts, packet.artifacts);
253        assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
254    }
255
256    #[test]
257    fn ingest_completion_message_updates_task_workflow_metadata() {
258        let tmp = tempfile::tempdir().unwrap();
259        let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
260        std::fs::create_dir_all(&tasks_dir).unwrap();
261        let task_path = tasks_dir.join("027-task.md");
262        std::fs::write(
263            &task_path,
264            "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: eng-1-4\nclass: standard\n---\n\nTask body.\n",
265        )
266        .unwrap();
267
268        let updated = ingest_completion_message(
269            tmp.path(),
270            r#"Done.
271
272## Completion Packet
273
274```json
275{"task_id":27,"branch":"eng-1-4/task-27","worktree_path":".batty/worktrees/eng-1-4","commit":"abc1234","changed_paths":["src/team/completion.rs"],"tests_run":true,"tests_passed":true,"artifacts":["docs/workflow.md"],"outcome":"ready_for_review"}
276```"#,
277        )
278        .unwrap();
279
280        assert_eq!(updated, Some(27));
281        let metadata = read_workflow_metadata(&task_path).unwrap();
282        assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
283        assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
284        assert_eq!(metadata.tests_run, Some(true));
285        assert!(metadata.review_blockers.is_empty());
286    }
287}