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(Debug, 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 use std::fmt::Write as _;
60
61 let parent_id = match store::opt_store_get_task(store, repo, &task.id.0)
63 .await
64 .and_then(|t| t.parent_id)
65 {
66 Some(id) => id.to_string(),
67 None => return String::new(),
68 };
69
70 let mut ctx = String::new();
71
72 let Ok(parent) = backend.get_task(&ExternalId(parent_id.clone())).await else {
74 return String::new();
75 };
76
77 let _ = writeln!(
78 ctx,
79 "## Parent Task #{}\n\n**Title**: {}\n\n{}\n",
80 parent.id.0, parent.title, parent.body
81 );
82
83 if let Ok(siblings) = backend.get_sub_issues(&ExternalId(parent_id)).await {
85 let siblings: Vec<ExternalId> = siblings.into_iter().filter(|s| s.0 != task.id.0).collect();
87
88 if !siblings.is_empty() {
89 ctx.push_str("## Sibling Tasks\n\n");
90
91 let task_futs = siblings.iter().map(|id| backend.get_task(id));
93 let task_results = join_all(task_futs).await;
94
95 let store_futs = siblings
97 .iter()
98 .map(|id| store::opt_store_get_task(store, repo, &id.0));
99 let store_results = join_all(store_futs).await;
100
101 for (i, maybe_task_res) in task_results.into_iter().enumerate() {
102 if let Ok(sib) = maybe_task_res {
103 let status = sib
104 .labels
105 .iter()
106 .find(|l| l.starts_with("status:"))
107 .map_or_else(|| "unknown".to_string(), |s| s.replace("status:", ""));
108 let _ = writeln!(ctx, "- #{} [{}]: {}", sib.id.0, status, sib.title);
109
110 if let Some(Some(task_rec)) = store_results.get(i) {
112 let summary = &task_rec.summary;
113 if !summary.is_empty() {
114 let _ = writeln!(ctx, " Summary: {summary}");
115 }
116 }
117 }
118 }
119
120 ctx.push('\n');
121 }
122 }
123
124 ctx
125}
126
127pub async fn build_project_instructions(project_dir: &Path) -> String {
129 use std::fmt::Write as _;
130
131 let mut instructions = String::new();
132 let mut seen = std::collections::HashSet::new();
133
134 for filename in &["CLAUDE.md", "AGENTS.md", "README.md"] {
135 let path = project_dir.join(filename);
136 let Ok(real) = tokio::fs::canonicalize(&path).await else {
138 continue;
139 };
140
141 if !seen.insert(real) {
142 continue;
143 }
144
145 if let Ok(content) = tokio::fs::read_to_string(&path).await
146 && !content.is_empty()
147 {
148 let _ = writeln!(instructions, "## {filename}\n\n{content}\n");
149 }
150 }
151
152 instructions
153}
154
155pub async fn build_skills_docs(selected_skills: &[String]) -> String {
157 use std::fmt::Write as _;
158
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 crate::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) = tokio::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: &crate::backends::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 use std::fmt::Write as _;
294
295 let id = crate::backends::ExternalId(task_id.to_string());
296 let all_comments = match backend.list_issue_comments(&id).await {
297 Ok(c) => c,
298 Err(e) => {
299 tracing::debug!(task_id, error = %e, "failed to fetch issue comments");
300 return String::new();
301 }
302 };
303
304 let human_comments: Vec<_> = all_comments
306 .into_iter()
307 .filter(|c| !is_bot_comment(c))
308 .collect();
309
310 let start = human_comments.len().saturating_sub(limit);
311 let mut formatted = String::new();
312 for comment in &human_comments[start..] {
313 let _ = writeln!(
314 formatted,
315 "**@{}** ({}):\n{}\n",
316 comment.author, comment.created_at, comment.body
317 );
318 }
319
320 formatted
321}
322
323pub async fn build_memory_context(
326 task_id: &str,
327 store: &Option<Arc<TaskStore>>,
328 repo: &str,
329) -> (String, Vec<crate::store::MemoryEntry>) {
330 use std::fmt::Write as _;
331
332 const MAX_MEMORY_ENTRIES: usize = 3;
333
334 let memory = match store::get_recent_memory_result(store, repo, task_id, MAX_MEMORY_ENTRIES)
335 .await
336 {
337 Ok(Some(entries)) => entries,
338 Ok(None) => Vec::new(),
339 Err(e) => {
340 tracing::warn!(task_id, error = %e, "build_memory_context: failed to read memory from store — proceeding without prior learnings");
341 Vec::new()
342 }
343 };
344
345 if memory.is_empty() {
346 return (String::new(), vec![]);
347 }
348
349 let mut context = String::new();
350 context.push_str("## Previous Attempts Memory\n\n");
351 context.push_str("Learnings from previous task attempts:\n\n");
352
353 for entry in &memory {
354 let _ = write!(
355 context,
356 "### Attempt #{} (Agent: {})",
357 entry.attempt, entry.agent
358 );
359
360 if let Some(ref model) = entry.model {
361 let _ = write!(context, ", Model: {model}");
362 }
363 context.push('\n');
364
365 if !entry.approach.is_empty() {
366 let _ = writeln!(context, "**Approach**: {}", entry.approach);
367 }
368
369 if !entry.learnings.is_empty() {
370 context.push_str("**Key Learnings**:\n");
371 for learning in &entry.learnings {
372 let _ = writeln!(context, "- {learning}");
373 }
374 }
375
376 if let Some(ref error) = entry.error {
377 let _ = writeln!(context, "**Error**: {error}");
378 }
379
380 if !entry.files_modified.is_empty() {
381 let _ = writeln!(
382 context,
383 "**Files Modified**: {}",
384 entry.files_modified.join(", ")
385 );
386 }
387
388 context.push('\n');
389 }
390
391 (context, memory)
392}
393
394pub async fn load_pr_review_context(
396 task_id: &str,
397 store: &Option<Arc<TaskStore>>,
398 repo: &str,
399) -> String {
400 store::opt_store_get_task(store, repo, task_id)
401 .await
402 .map(|t| t.pr_review_context)
403 .unwrap_or_default()
404}
405
406#[expect(
408 clippy::too_many_arguments,
409 reason = "each argument is a separate context source"
410)]
411pub async fn build_full_context(
412 task: &ExternalTask,
413 backend: Option<&dyn ExternalBackend>,
414 project_dir: &Path,
415 default_branch: &str,
416 attempts: u32,
417 selected_skills: &[String],
418 store: &Option<Arc<TaskStore>>,
419 repo: &str,
420) -> TaskContext {
421 let (
422 task_context,
423 parent_context,
424 project_instructions,
425 skills_docs,
426 repo_tree,
427 git_diff,
428 issue_comments,
429 pr_review_context,
430 (_, memory),
431 ) = tokio::join!(
432 load_task_context(&task.id.0),
433 async {
434 if let Some(b) = backend {
435 build_parent_context(task, b, store, repo).await
436 } else {
437 String::new()
438 }
439 },
440 build_project_instructions(project_dir),
441 build_skills_docs(selected_skills),
442 build_repo_tree(project_dir),
443 async {
444 if attempts > 0 {
445 build_git_diff(project_dir, default_branch).await
446 } else {
447 String::new()
448 }
449 },
450 async {
451 if let Some(b) = backend {
452 fetch_issue_comments(b, &task.id.0, 10).await
453 } else {
454 String::new()
455 }
456 },
457 load_pr_review_context(&task.id.0, store, repo),
458 build_memory_context(&task.id.0, store, repo),
459 );
460
461 TaskContext {
462 task_context,
463 parent_context,
464 project_instructions,
465 skills_docs,
466 repo_tree,
467 git_diff,
468 issue_comments,
469 pr_review_context,
470 memory,
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[tokio::test]
479 async fn test_build_memory_context_empty() {
480 let (context, memory) = build_memory_context("nonexistent-task-12345", &None, "").await;
481 assert_eq!(context, "");
482 assert!(memory.is_empty(), "{memory:?}");
483 }
484
485 #[tokio::test]
486 async fn test_build_project_instructions_empty_dir() {
487 let dir = std::env::temp_dir().join("orch_test_nonexistent");
488 let instructions = build_project_instructions(&dir).await;
489 assert_eq!(instructions, "");
490 }
491
492 #[cfg(unix)]
493 #[tokio::test]
494 async fn project_instructions_read_a_symlinked_file_once() {
495 let dir = tempfile::tempdir().unwrap();
496 std::fs::write(dir.path().join("AGENTS.md"), "agent rules").unwrap();
497 std::os::unix::fs::symlink("AGENTS.md", dir.path().join("CLAUDE.md")).unwrap();
498 std::fs::write(dir.path().join("README.md"), "readme").unwrap();
499
500 let instructions = build_project_instructions(dir.path()).await;
501 assert_eq!(instructions.matches("agent rules").count(), 1);
502 assert!(instructions.contains("## CLAUDE.md"));
503 assert!(!instructions.contains("## AGENTS.md"));
504 assert!(instructions.contains("readme"));
505 }
506
507 #[tokio::test]
508 async fn test_build_skills_docs_empty() {
509 let docs = build_skills_docs(&[]).await;
510 assert_eq!(docs, "");
511 }
512
513 #[test]
514 fn test_task_context_default_values() {
515 let ctx = TaskContext {
516 task_context: String::new(),
517 parent_context: String::new(),
518 project_instructions: String::new(),
519 skills_docs: String::new(),
520 repo_tree: String::new(),
521 git_diff: String::new(),
522 issue_comments: String::new(),
523 pr_review_context: String::new(),
524 memory: vec![],
525 };
526
527 assert_eq!(ctx.task_context, "");
528 assert!(ctx.memory.is_empty(), "{:?}", ctx.memory);
529 }
530}