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}