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}