1use crate::backends::{ExternalBackend, ExternalId, ExternalTask};
13use crate::cmd::CommandErrorContext;
14use crate::store;
15use crate::store::TaskStore;
16use futures::future::join_all;
17use std::path::Path;
18use std::sync::Arc;
19use tokio::process::Command;
20
21pub struct TaskContext {
23 pub task_context: String,
25 pub parent_context: String,
27 pub project_instructions: String,
29 pub skills_docs: String,
31 pub repo_tree: String,
33 pub git_diff: String,
35 pub issue_comments: String,
37 pub pr_review_context: String,
39 pub memory: Vec<crate::store::MemoryEntry>,
41}
42
43pub async fn load_task_context(task_id: &str) -> String {
45 let contexts_dir = crate::home::contexts_dir().unwrap_or_default();
46
47 let path = contexts_dir.join(format!("task-{task_id}.md"));
48 tokio::fs::read_to_string(&path).await.unwrap_or_default()
49}
50
51pub async fn build_parent_context(
53 task: &ExternalTask,
54 backend: &dyn ExternalBackend,
55 store: &Option<Arc<TaskStore>>,
56 repo: &str,
57) -> String {
58 let parent_id = match store::opt_store_get_task(store, repo, &task.id.0)
60 .await
61 .and_then(|t| t.parent_id)
62 {
63 Some(id) => id.to_string(),
64 None => return String::new(),
65 };
66
67 let mut ctx = String::new();
68
69 let parent = match backend.get_task(&ExternalId(parent_id.clone())).await {
71 Ok(t) => t,
72 Err(_) => return String::new(),
73 };
74
75 ctx.push_str(&format!(
76 "## Parent Task #{}\n\n**Title**: {}\n\n{}\n\n",
77 parent.id.0, parent.title, parent.body
78 ));
79
80 if let Ok(siblings) = backend.get_sub_issues(&ExternalId(parent_id)).await {
82 let siblings: Vec<ExternalId> = siblings.into_iter().filter(|s| s.0 != task.id.0).collect();
84
85 if !siblings.is_empty() {
86 ctx.push_str("## Sibling Tasks\n\n");
87
88 let task_futs = siblings.iter().map(|id| backend.get_task(id));
90 let task_results = join_all(task_futs).await;
91
92 let store_futs = siblings
94 .iter()
95 .map(|id| store::opt_store_get_task(store, repo, &id.0));
96 let store_results = join_all(store_futs).await;
97
98 for (i, maybe_task_res) in task_results.into_iter().enumerate() {
99 if let Ok(sib) = maybe_task_res {
100 let status = sib
101 .labels
102 .iter()
103 .find(|l| l.starts_with("status:"))
104 .map(|s| s.replace("status:", ""))
105 .unwrap_or_else(|| "unknown".to_string());
106 ctx.push_str(&format!("- #{} [{}]: {}\n", sib.id.0, status, sib.title));
107
108 if let Some(Some(task_rec)) = store_results.get(i) {
110 let summary = &task_rec.summary;
111 if !summary.is_empty() {
112 ctx.push_str(&format!(" Summary: {}\n", summary));
113 }
114 }
115 }
116 }
117
118 ctx.push('\n');
119 }
120 }
121
122 ctx
123}
124
125pub async fn build_project_instructions(project_dir: &Path) -> String {
127 let mut instructions = String::new();
128
129 for filename in &["CLAUDE.md", "AGENTS.md", "README.md"] {
130 let path = project_dir.join(filename);
131 if let Ok(content) = tokio::fs::read_to_string(&path).await {
132 if !content.is_empty() {
133 instructions.push_str(&format!("## {filename}\n\n{content}\n\n"));
134 }
135 }
136 }
137
138 instructions
139}
140
141pub async fn build_skills_docs(selected_skills: &[String]) -> String {
143 if selected_skills.is_empty() {
144 return String::new();
145 }
146
147 let mut docs = String::new();
148 let skills_dirs = [
149 dirs::home_dir()
150 .unwrap_or_default()
151 .join(".claude")
152 .join("skills"),
153 crate::home::skills_dir().unwrap_or_default(),
154 ];
155
156 for skill in selected_skills {
157 for dir in &skills_dirs {
158 let skill_file = dir.join(skill).join("SKILL.md");
159 if let Ok(content) = tokio::fs::read_to_string(&skill_file).await {
160 docs.push_str(&format!("## Skill: {skill}\n\n{content}\n\n"));
161 break;
162 }
163 }
164 }
165
166 docs
167}
168
169pub async fn build_repo_tree(project_dir: &Path) -> String {
171 let output = Command::new("git")
172 .args(["ls-files"])
173 .current_dir(project_dir)
174 .output_with_context()
175 .await;
176
177 match output {
178 Ok(o) if o.status.success() => {
179 let full = String::from_utf8_lossy(&o.stdout);
180 let all_lines: Vec<&str> = full.lines().collect();
181 let total = all_lines.len();
182 if total > 200 {
183 format!(
184 "{}\n... (truncated, {} total files)",
185 all_lines[..200].join("\n"),
186 total
187 )
188 } else {
189 all_lines.join("\n")
190 }
191 }
192 _ => String::new(),
193 }
194}
195
196pub async fn build_git_diff(project_dir: &Path, default_branch: &str) -> String {
198 let output = Command::new("git")
199 .args(["diff", &format!("origin/{default_branch}...HEAD")])
200 .current_dir(project_dir)
201 .output_with_context()
202 .await;
203
204 match output {
205 Ok(o) if o.status.success() => {
206 let diff = String::from_utf8_lossy(&o.stdout);
207 if diff.len() > 10000 {
209 let mut boundary = 10000;
211 while !diff.is_char_boundary(boundary) {
212 boundary -= 1;
213 }
214 format!("{}...\n(diff truncated at 10000 chars)", &diff[..boundary])
215 } else {
216 diff.to_string()
217 }
218 }
219 _ => String::new(),
220 }
221}
222
223pub async fn build_git_log(project_dir: &Path, default_branch: &str) -> String {
226 let output = Command::new("git")
227 .args([
228 "log",
229 "--oneline",
230 &format!("origin/{default_branch}..HEAD"),
231 ])
232 .current_dir(project_dir)
233 .output_with_context()
234 .await;
235
236 match output {
237 Ok(o) if o.status.success() => {
238 let log = String::from_utf8_lossy(&o.stdout);
239 let all_lines: Vec<&str> = log.lines().collect();
240 let total = all_lines.len();
241 if total > 100 {
242 format!(
243 "{}\n... (truncated, {} total commits)",
244 all_lines[..100].join("\n"),
245 total
246 )
247 } else {
248 all_lines.join("\n")
249 }
250 }
251 _ => String::new(),
252 }
253}
254
255fn is_bot_comment(mention: &crate::backends::Mention) -> bool {
257 if mention.author.ends_with("[bot]") {
259 return true;
260 }
261 if mention.body.starts_with("## Automated Review") {
263 return true;
264 }
265 false
266}
267
268pub async fn fetch_issue_comments(
273 backend: &dyn ExternalBackend,
274 task_id: &str,
275 limit: usize,
276) -> String {
277 let id = crate::backends::ExternalId(task_id.to_string());
278 let all_comments = match backend.list_issue_comments(&id).await {
279 Ok(c) => c,
280 Err(e) => {
281 tracing::debug!(task_id, error = %e, "failed to fetch issue comments");
282 return String::new();
283 }
284 };
285
286 let human_comments: Vec<_> = all_comments
288 .into_iter()
289 .filter(|c| !is_bot_comment(c))
290 .collect();
291
292 let start = human_comments.len().saturating_sub(limit);
293 let mut formatted = String::new();
294 for comment in &human_comments[start..] {
295 formatted.push_str(&format!(
296 "**@{}** ({}):\n{}\n\n",
297 comment.author, comment.created_at, comment.body
298 ));
299 }
300
301 formatted
302}
303
304pub async fn build_memory_context(
307 task_id: &str,
308 store: &Option<Arc<TaskStore>>,
309 repo: &str,
310) -> (String, Vec<crate::store::MemoryEntry>) {
311 const MAX_MEMORY_ENTRIES: usize = 3;
312
313 let memory = match store::get_recent_memory_result(store, repo, task_id, MAX_MEMORY_ENTRIES)
314 .await
315 {
316 Ok(Some(entries)) => entries,
317 Ok(None) => Vec::new(),
318 Err(e) => {
319 tracing::warn!(task_id, error = %e, "build_memory_context: failed to read memory from store — proceeding without prior learnings");
320 Vec::new()
321 }
322 };
323
324 if memory.is_empty() {
325 return (String::new(), vec![]);
326 }
327
328 let mut context = String::new();
329 context.push_str("## Previous Attempts Memory\n\n");
330 context.push_str("Learnings from previous task attempts:\n\n");
331
332 for entry in &memory {
333 context.push_str(&format!(
334 "### Attempt #{} (Agent: {})",
335 entry.attempt, entry.agent
336 ));
337
338 if let Some(ref model) = entry.model {
339 context.push_str(&format!(", Model: {}", model));
340 }
341 context.push('\n');
342
343 if !entry.approach.is_empty() {
344 context.push_str(&format!("**Approach**: {}\n", entry.approach));
345 }
346
347 if !entry.learnings.is_empty() {
348 context.push_str("**Key Learnings**:\n");
349 for learning in &entry.learnings {
350 context.push_str(&format!("- {}\n", learning));
351 }
352 }
353
354 if let Some(ref error) = entry.error {
355 context.push_str(&format!("**Error**: {}\n", error));
356 }
357
358 if !entry.files_modified.is_empty() {
359 context.push_str(&format!(
360 "**Files Modified**: {}\n",
361 entry.files_modified.join(", ")
362 ));
363 }
364
365 context.push('\n');
366 }
367
368 (context, memory)
369}
370
371pub async fn load_pr_review_context(
373 task_id: &str,
374 store: &Option<Arc<TaskStore>>,
375 repo: &str,
376) -> String {
377 store::opt_store_get_task(store, repo, task_id)
378 .await
379 .map(|t| t.pr_review_context)
380 .unwrap_or_default()
381}
382
383#[allow(clippy::too_many_arguments)]
385pub async fn build_full_context(
386 task: &ExternalTask,
387 backend: Option<&dyn ExternalBackend>,
388 project_dir: &Path,
389 default_branch: &str,
390 attempts: u32,
391 selected_skills: &[String],
392 store: &Option<Arc<TaskStore>>,
393 repo: &str,
394) -> TaskContext {
395 let (
396 task_context,
397 parent_context,
398 project_instructions,
399 skills_docs,
400 repo_tree,
401 git_diff,
402 issue_comments,
403 pr_review_context,
404 (_, memory),
405 ) = tokio::join!(
406 load_task_context(&task.id.0),
407 async {
408 if let Some(b) = backend {
409 build_parent_context(task, b, store, repo).await
410 } else {
411 String::new()
412 }
413 },
414 build_project_instructions(project_dir),
415 build_skills_docs(selected_skills),
416 build_repo_tree(project_dir),
417 async {
418 if attempts > 0 {
419 build_git_diff(project_dir, default_branch).await
420 } else {
421 String::new()
422 }
423 },
424 async {
425 if let Some(b) = backend {
426 fetch_issue_comments(b, &task.id.0, 10).await
427 } else {
428 String::new()
429 }
430 },
431 load_pr_review_context(&task.id.0, store, repo),
432 build_memory_context(&task.id.0, store, repo),
433 );
434
435 TaskContext {
436 task_context,
437 parent_context,
438 project_instructions,
439 skills_docs,
440 repo_tree,
441 git_diff,
442 issue_comments,
443 pr_review_context,
444 memory,
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[tokio::test]
453 async fn test_build_memory_context_empty() {
454 let (context, memory) = build_memory_context("nonexistent-task-12345", &None, "").await;
455 assert!(context.is_empty());
456 assert!(memory.is_empty());
457 }
458
459 #[tokio::test]
460 async fn test_build_project_instructions_empty_dir() {
461 let dir = std::env::temp_dir().join("orch_test_nonexistent");
462 let instructions = build_project_instructions(&dir).await;
463 assert!(instructions.is_empty());
464 }
465
466 #[tokio::test]
467 async fn test_build_skills_docs_empty() {
468 let docs = build_skills_docs(&[]).await;
469 assert!(docs.is_empty());
470 }
471
472 #[test]
473 fn test_task_context_default_values() {
474 let ctx = TaskContext {
475 task_context: String::new(),
476 parent_context: String::new(),
477 project_instructions: String::new(),
478 skills_docs: String::new(),
479 repo_tree: String::new(),
480 git_diff: String::new(),
481 issue_comments: String::new(),
482 pr_review_context: String::new(),
483 memory: vec![],
484 };
485
486 assert!(ctx.task_context.is_empty());
487 assert!(ctx.memory.is_empty());
488 }
489}