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