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::daemon::verification;
10use super::team_config_dir;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct CompletionPacket {
14    pub task_id: u32,
15    pub branch: Option<String>,
16    pub worktree_path: Option<String>,
17    pub commit: Option<String>,
18    #[serde(default)]
19    pub changed_paths: Vec<String>,
20    pub tests_run: bool,
21    pub tests_passed: bool,
22    #[serde(default)]
23    pub artifacts: Vec<String>,
24    pub outcome: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CompletionValidation {
29    pub is_complete: bool,
30    pub missing_fields: Vec<String>,
31    pub warnings: Vec<String>,
32}
33
34pub fn parse_completion(text: &str) -> Result<CompletionPacket> {
35    let content = extract_packet_text(text).unwrap_or(text).trim();
36
37    serde_json::from_str(content)
38        .or_else(|_| serde_yaml::from_str(content))
39        .context("failed to parse completion packet as JSON or YAML")
40}
41
42pub fn validate_completion(packet: &CompletionPacket) -> CompletionValidation {
43    let mut missing_fields = Vec::new();
44    let mut warnings = Vec::new();
45
46    if packet.task_id == 0 {
47        missing_fields.push("task_id".to_string());
48    }
49    if packet.branch.as_deref().is_none_or(str::is_empty) {
50        missing_fields.push("branch".to_string());
51    }
52    if packet.commit.as_deref().is_none_or(str::is_empty) {
53        missing_fields.push("commit".to_string());
54    }
55    if !packet.tests_run {
56        missing_fields.push("tests_run".to_string());
57    }
58    if packet.worktree_path.as_deref().is_none_or(str::is_empty) {
59        warnings.push("worktree_path missing".to_string());
60    }
61    if !packet.tests_passed {
62        warnings.push("tests_passed is false".to_string());
63    }
64    if packet.outcome.trim().is_empty() {
65        warnings.push("outcome missing".to_string());
66    }
67
68    CompletionValidation {
69        is_complete: missing_fields.is_empty(),
70        missing_fields,
71        warnings,
72    }
73}
74
75pub fn apply_completion_to_metadata(packet: &CompletionPacket, metadata: &mut WorkflowMetadata) {
76    metadata.branch = packet.branch.clone();
77    metadata.worktree_path = packet.worktree_path.clone();
78    metadata.commit = packet.commit.clone();
79    metadata.changed_paths = packet.changed_paths.clone();
80    metadata.tests_run = Some(packet.tests_run);
81    metadata.tests_passed = Some(packet.tests_passed);
82    metadata.artifacts = packet.artifacts.clone();
83    metadata.outcome = Some(packet.outcome.clone());
84}
85
86fn scope_review_blockers(
87    project_root: &Path,
88    task_text: &str,
89    packet: &CompletionPacket,
90) -> Result<Vec<String>> {
91    let worktree_dir = resolve_worktree_path(project_root, packet)?;
92    if !worktree_dir.exists() {
93        return Ok(Vec::new());
94    }
95
96    let changed_files = verification::changed_files_from_main(&worktree_dir)?;
97    let scope = verification::validate_declared_scope(task_text, &changed_files);
98    if scope.declared_scope.is_empty() || scope.out_of_scope_files.is_empty() {
99        return Ok(Vec::new());
100    }
101
102    Ok(vec![format!(
103        "scope fence violation: changed files outside declared scope: {}",
104        scope.out_of_scope_files.join(", ")
105    )])
106}
107
108pub(crate) fn ingest_completion_message(project_root: &Path, message: &str) -> Result<Option<u32>> {
109    if !message.contains("Completion Packet") {
110        return Ok(None);
111    }
112
113    let packet = parse_completion(message)?;
114    if !packet.tests_passed {
115        anyhow::bail!("completion packet rejected: tests_passed must be true");
116    }
117    let validation = validate_completion(&packet);
118    let task_path = find_task_path(project_root, packet.task_id)?;
119    let task_text = std::fs::read_to_string(&task_path)
120        .with_context(|| format!("failed to read {}", task_path.display()))?;
121    let mut metadata = read_workflow_metadata(&task_path)?;
122    apply_completion_to_metadata(&packet, &mut metadata);
123    let mut review_blockers = validation.missing_fields;
124    review_blockers.extend(scope_review_blockers(project_root, &task_text, &packet)?);
125    if packet.outcome.trim() == "ready_for_review"
126        && review_blockers.is_empty()
127        && let Ok(worktree_path) = resolve_worktree_path(project_root, &packet)
128        && worktree_path.exists()
129    {
130        review_blockers.extend(crate::team::task_loop::validate_review_ready_worktree(
131            &worktree_path,
132            &task_text,
133        )?);
134    }
135    metadata.review_blockers = review_blockers;
136    write_workflow_metadata(&task_path, &metadata)?;
137    Ok(Some(packet.task_id))
138}
139
140fn resolve_worktree_path(project_root: &Path, packet: &CompletionPacket) -> Result<PathBuf> {
141    let raw_path = packet
142        .worktree_path
143        .as_deref()
144        .filter(|value| !value.trim().is_empty())
145        .context("worktree_path missing for commit validation")?;
146    let path = PathBuf::from(raw_path);
147    if path.is_absolute() {
148        Ok(path)
149    } else {
150        Ok(project_root.join(path))
151    }
152}
153
154fn extract_packet_text(text: &str) -> Option<&str> {
155    if let Some(start) = text.find("```") {
156        let after_fence = &text[start + 3..];
157        let inner_start = after_fence.find('\n').map(|i| i + 1).unwrap_or(0);
158        let inner = &after_fence[inner_start..];
159        if let Some(end) = inner.find("```") {
160            return Some(inner[..end].trim());
161        }
162    }
163
164    text.find("## Completion Packet")
165        .map(|idx| &text[idx + "## Completion Packet".len()..])
166        .map(str::trim)
167        .filter(|content| !content.is_empty())
168}
169
170fn find_task_path(project_root: &Path, task_id: u32) -> Result<PathBuf> {
171    let tasks_dir = team_config_dir(project_root).join("board").join("tasks");
172    let tasks = load_tasks_from_dir(&tasks_dir)
173        .with_context(|| format!("failed to load tasks from {}", tasks_dir.display()))?;
174    tasks
175        .into_iter()
176        .find(|task| task.id == task_id)
177        .map(|task| task.source_path)
178        .with_context(|| format!("task #{task_id} not found in {}", tasks_dir.display()))
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn parse_completion_parses_json() {
187        let packet = parse_completion(
188            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"}"#,
189        )
190        .unwrap();
191
192        assert_eq!(packet.task_id, 27);
193        assert_eq!(packet.branch.as_deref(), Some("eng-1-4/task-27"));
194        assert!(packet.tests_run);
195        assert!(packet.tests_passed);
196    }
197
198    #[test]
199    fn parse_completion_parses_fenced_yaml_block() {
200        let packet = parse_completion(
201            r#"Done.
202
203## Completion Packet
204
205```yaml
206task_id: 27
207branch: eng-1-4/task-27
208worktree_path: .batty/worktrees/eng-1-4
209commit: abc1234
210changed_paths:
211  - src/team/completion.rs
212tests_run: true
213tests_passed: false
214artifacts:
215  - docs/workflow.md
216outcome: ready_for_review
217```"#,
218        )
219        .unwrap();
220
221        assert_eq!(packet.task_id, 27);
222        assert_eq!(packet.commit.as_deref(), Some("abc1234"));
223        assert_eq!(packet.artifacts, vec!["docs/workflow.md"]);
224        assert!(!packet.tests_passed);
225    }
226
227    #[test]
228    fn parse_completion_returns_error_for_malformed_packet() {
229        let error = parse_completion("{not valid").unwrap_err().to_string();
230        assert!(error.contains("failed to parse completion packet"));
231    }
232
233    #[test]
234    fn validate_completion_reports_complete_packet() {
235        let validation = validate_completion(&CompletionPacket {
236            task_id: 27,
237            branch: Some("eng-1-4/task-27".to_string()),
238            worktree_path: Some(".batty/worktrees/eng-1-4".to_string()),
239            commit: Some("abc1234".to_string()),
240            changed_paths: vec!["src/team/completion.rs".to_string()],
241            tests_run: true,
242            tests_passed: true,
243            artifacts: vec!["docs/workflow.md".to_string()],
244            outcome: "ready_for_review".to_string(),
245        });
246
247        assert!(validation.is_complete);
248        assert!(validation.missing_fields.is_empty());
249        assert!(validation.warnings.is_empty());
250    }
251
252    #[test]
253    fn validate_completion_reports_missing_required_fields() {
254        let validation = validate_completion(&CompletionPacket {
255            task_id: 0,
256            branch: None,
257            worktree_path: None,
258            commit: None,
259            changed_paths: Vec::new(),
260            tests_run: false,
261            tests_passed: false,
262            artifacts: Vec::new(),
263            outcome: String::new(),
264        });
265
266        assert!(!validation.is_complete);
267        assert_eq!(
268            validation.missing_fields,
269            vec!["task_id", "branch", "commit", "tests_run"]
270        );
271        assert!(
272            validation
273                .warnings
274                .contains(&"worktree_path missing".to_string())
275        );
276        assert!(
277            validation
278                .warnings
279                .contains(&"tests_passed is false".to_string())
280        );
281    }
282
283    #[test]
284    fn apply_completion_to_metadata_copies_fields() {
285        let packet = CompletionPacket {
286            task_id: 27,
287            branch: Some("eng-1-4/task-27".to_string()),
288            worktree_path: Some(".batty/worktrees/eng-1-4".to_string()),
289            commit: Some("abc1234".to_string()),
290            changed_paths: vec!["src/team/completion.rs".to_string()],
291            tests_run: true,
292            tests_passed: true,
293            artifacts: vec!["docs/workflow.md".to_string()],
294            outcome: "ready_for_review".to_string(),
295        };
296        let mut metadata = WorkflowMetadata::default();
297
298        apply_completion_to_metadata(&packet, &mut metadata);
299
300        assert_eq!(metadata.branch, packet.branch);
301        assert_eq!(metadata.worktree_path, packet.worktree_path);
302        assert_eq!(metadata.commit, packet.commit);
303        assert_eq!(metadata.changed_paths, packet.changed_paths);
304        assert_eq!(metadata.tests_run, Some(true));
305        assert_eq!(metadata.tests_passed, Some(true));
306        assert_eq!(metadata.artifacts, packet.artifacts);
307        assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
308    }
309
310    #[test]
311    fn ingest_completion_message_adds_scope_fence_review_blocker() {
312        let tmp = tempfile::tempdir().unwrap();
313        let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
314        std::fs::create_dir_all(&tasks_dir).unwrap();
315        let task_path = tasks_dir.join("027-task.md");
316        std::fs::write(
317            &task_path,
318            "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: eng-1-4\nclass: standard\n---\n\nTask body.\nSCOPE FENCE: src/team/completion.rs, src/team/review.rs\n",
319        )
320        .unwrap();
321
322        let worktree = tmp.path().join(".batty").join("worktrees").join("eng-1-4");
323        std::fs::create_dir_all(worktree.join("src/team")).unwrap();
324        let git = |args: &[&str]| {
325            std::process::Command::new("git")
326                .args(args)
327                .current_dir(&worktree)
328                .output()
329                .unwrap()
330        };
331        assert!(git(&["init"]).status.success());
332        assert!(
333            git(&["config", "user.email", "test@example.com"])
334                .status
335                .success()
336        );
337        assert!(git(&["config", "user.name", "Test"]).status.success());
338        std::fs::write(worktree.join("src/team/completion.rs"), "base\n").unwrap();
339        assert!(git(&["add", "."]).status.success());
340        assert!(git(&["commit", "-m", "base"]).status.success());
341        assert!(git(&["branch", "-M", "main"]).status.success());
342        assert!(git(&["checkout", "-b", "eng-1-4"]).status.success());
343
344        std::fs::write(worktree.join("src/team/review.rs"), "in scope\n").unwrap();
345        std::fs::write(worktree.join("src/team/daemon.rs"), "out of scope\n").unwrap();
346        assert!(git(&["add", "."]).status.success());
347        assert!(git(&["commit", "-m", "change"]).status.success());
348
349        let updated = ingest_completion_message(
350            tmp.path(),
351            r#"Done.
352
353## Completion Packet
354
355```json
356{"task_id":27,"branch":"eng-1-4/task-27","worktree_path":".batty/worktrees/eng-1-4","commit":"abc1234","changed_paths":["src/team/review.rs","src/team/daemon.rs"],"tests_run":true,"tests_passed":true,"artifacts":[],"outcome":"ready_for_review"}
357```"#,
358        )
359        .unwrap();
360
361        assert_eq!(updated, Some(27));
362        let metadata = read_workflow_metadata(&task_path).unwrap();
363        assert!(
364            metadata
365                .review_blockers
366                .iter()
367                .any(|blocker| blocker.contains("src/team/daemon.rs"))
368        );
369    }
370
371    #[test]
372    fn ingest_completion_message_updates_task_workflow_metadata() {
373        let tmp = tempfile::tempdir().unwrap();
374        let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
375        std::fs::create_dir_all(&tasks_dir).unwrap();
376        let task_path = tasks_dir.join("027-task.md");
377        std::fs::write(
378            &task_path,
379            "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: eng-1-4\nclass: standard\n---\n\nTask body.\n",
380        )
381        .unwrap();
382
383        let updated = ingest_completion_message(
384            tmp.path(),
385            r#"Done.
386
387## Completion Packet
388
389```json
390{"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"}
391```"#,
392        )
393        .unwrap();
394
395        assert_eq!(updated, Some(27));
396        let metadata = read_workflow_metadata(&task_path).unwrap();
397        assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
398        assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
399        assert_eq!(metadata.tests_run, Some(true));
400        assert!(metadata.review_blockers.is_empty());
401    }
402
403    #[test]
404    fn ingest_completion_message_rejects_failed_tests() {
405        let tmp = tempfile::tempdir().unwrap();
406        let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
407        std::fs::create_dir_all(&tasks_dir).unwrap();
408        let task_path = tasks_dir.join("027-task.md");
409        std::fs::write(
410            &task_path,
411            "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: eng-1-4\nclass: standard\n---\n\nTask body.\n",
412        )
413        .unwrap();
414
415        let error = ingest_completion_message(
416            tmp.path(),
417            r#"Done.
418
419## Completion Packet
420
421```json
422{"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":false,"artifacts":[],"outcome":"ready_for_review"}
423```"#,
424        )
425        .unwrap_err()
426        .to_string();
427
428        assert!(error.contains("tests_passed must be true"));
429        let metadata = read_workflow_metadata(&task_path).unwrap();
430        assert!(metadata.branch.is_none());
431        assert!(metadata.review_blockers.is_empty());
432    }
433}