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