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 let mut seen = std::collections::HashSet::new();
130
131 for filename in &["CLAUDE.md", "AGENTS.md", "README.md"] {
132 let path = project_dir.join(filename);
133 let Ok(real) = tokio::fs::canonicalize(&path).await else {
135 continue;
136 };
137
138 if !seen.insert(real) {
139 continue;
140 }
141
142 if let Ok(content) = tokio::fs::read_to_string(&path).await {
143 if !content.is_empty() {
144 instructions.push_str(&format!("## {filename}\n\n{content}\n\n"));
145 }
146 }
147 }
148
149 instructions
150}
151
152pub async fn build_skills_docs(selected_skills: &[String]) -> String {
154 if selected_skills.is_empty() {
155 return String::new();
156 }
157
158 let mut docs = String::new();
159 let skills_dirs = [
160 dirs::home_dir()
161 .unwrap_or_default()
162 .join(".claude")
163 .join("skills"),
164 crate::home::skills_dir().unwrap_or_default(),
165 ];
166
167 for skill in selected_skills {
168 for dir in &skills_dirs {
169 let skill_file = dir.join(skill).join("SKILL.md");
170 if let Ok(content) = tokio::fs::read_to_string(&skill_file).await {
171 docs.push_str(&format!("## Skill: {skill}\n\n{content}\n\n"));
172 break;
173 }
174 }
175 }
176
177 docs
178}
179
180pub async fn build_repo_tree(project_dir: &Path) -> String {
182 let output = Command::new("git")
183 .args(["ls-files"])
184 .current_dir(project_dir)
185 .output_with_context()
186 .await;
187
188 match output {
189 Ok(o) if o.status.success() => {
190 let full = String::from_utf8_lossy(&o.stdout);
191 let all_lines: Vec<&str> = full.lines().collect();
192 let total = all_lines.len();
193 if total > 200 {
194 format!(
195 "{}\n... (truncated, {} total files)",
196 all_lines[..200].join("\n"),
197 total
198 )
199 } else {
200 all_lines.join("\n")
201 }
202 }
203 _ => String::new(),
204 }
205}
206
207pub async fn build_git_diff(project_dir: &Path, default_branch: &str) -> String {
209 let output = Command::new("git")
210 .args(["diff", &format!("origin/{default_branch}...HEAD")])
211 .current_dir(project_dir)
212 .output_with_context()
213 .await;
214
215 match output {
216 Ok(o) if o.status.success() => {
217 let diff = String::from_utf8_lossy(&o.stdout);
218 if diff.len() > 10000 {
220 let mut boundary = 10000;
222 while !diff.is_char_boundary(boundary) {
223 boundary -= 1;
224 }
225 format!("{}...\n(diff truncated at 10000 chars)", &diff[..boundary])
226 } else {
227 diff.to_string()
228 }
229 }
230 _ => String::new(),
231 }
232}
233
234pub async fn build_git_log(project_dir: &Path, default_branch: &str) -> String {
237 let output = Command::new("git")
238 .args([
239 "log",
240 "--oneline",
241 &format!("origin/{default_branch}..HEAD"),
242 ])
243 .current_dir(project_dir)
244 .output_with_context()
245 .await;
246
247 match output {
248 Ok(o) if o.status.success() => {
249 let log = String::from_utf8_lossy(&o.stdout);
250 let all_lines: Vec<&str> = log.lines().collect();
251 let total = all_lines.len();
252 if total > 100 {
253 format!(
254 "{}\n... (truncated, {} total commits)",
255 all_lines[..100].join("\n"),
256 total
257 )
258 } else {
259 all_lines.join("\n")
260 }
261 }
262 _ => String::new(),
263 }
264}
265
266fn is_bot_comment(mention: &crate::backends::Mention) -> bool {
268 if mention.author.ends_with("[bot]") {
270 return true;
271 }
272 if mention.body.starts_with("## Automated Review") {
274 return true;
275 }
276 false
277}
278
279pub async fn fetch_issue_comments(
284 backend: &dyn ExternalBackend,
285 task_id: &str,
286 limit: usize,
287) -> String {
288 let id = crate::backends::ExternalId(task_id.to_string());
289 let all_comments = match backend.list_issue_comments(&id).await {
290 Ok(c) => c,
291 Err(e) => {
292 tracing::debug!(task_id, error = %e, "failed to fetch issue comments");
293 return String::new();
294 }
295 };
296
297 let human_comments: Vec<_> = all_comments
299 .into_iter()
300 .filter(|c| !is_bot_comment(c))
301 .collect();
302
303 let start = human_comments.len().saturating_sub(limit);
304 let mut formatted = String::new();
305 for comment in &human_comments[start..] {
306 formatted.push_str(&format!(
307 "**@{}** ({}):\n{}\n\n",
308 comment.author, comment.created_at, comment.body
309 ));
310 }
311
312 formatted
313}
314
315pub async fn build_memory_context(
318 task_id: &str,
319 store: &Option<Arc<TaskStore>>,
320 repo: &str,
321) -> (String, Vec<crate::store::MemoryEntry>) {
322 const MAX_MEMORY_ENTRIES: usize = 3;
323
324 let memory = match store::get_recent_memory_result(store, repo, task_id, MAX_MEMORY_ENTRIES)
325 .await
326 {
327 Ok(Some(entries)) => entries,
328 Ok(None) => Vec::new(),
329 Err(e) => {
330 tracing::warn!(task_id, error = %e, "build_memory_context: failed to read memory from store — proceeding without prior learnings");
331 Vec::new()
332 }
333 };
334
335 if memory.is_empty() {
336 return (String::new(), vec![]);
337 }
338
339 let mut context = String::new();
340 context.push_str("## Previous Attempts Memory\n\n");
341 context.push_str("Learnings from previous task attempts:\n\n");
342
343 for entry in &memory {
344 context.push_str(&format!(
345 "### Attempt #{} (Agent: {})",
346 entry.attempt, entry.agent
347 ));
348
349 if let Some(ref model) = entry.model {
350 context.push_str(&format!(", Model: {}", model));
351 }
352 context.push('\n');
353
354 if !entry.approach.is_empty() {
355 context.push_str(&format!("**Approach**: {}\n", entry.approach));
356 }
357
358 if !entry.learnings.is_empty() {
359 context.push_str("**Key Learnings**:\n");
360 for learning in &entry.learnings {
361 context.push_str(&format!("- {}\n", learning));
362 }
363 }
364
365 if let Some(ref error) = entry.error {
366 context.push_str(&format!("**Error**: {}\n", error));
367 }
368
369 if !entry.files_modified.is_empty() {
370 context.push_str(&format!(
371 "**Files Modified**: {}\n",
372 entry.files_modified.join(", ")
373 ));
374 }
375
376 context.push('\n');
377 }
378
379 (context, memory)
380}
381
382pub async fn load_pr_review_context(
384 task_id: &str,
385 store: &Option<Arc<TaskStore>>,
386 repo: &str,
387) -> String {
388 store::opt_store_get_task(store, repo, task_id)
389 .await
390 .map(|t| t.pr_review_context)
391 .unwrap_or_default()
392}
393
394#[allow(clippy::too_many_arguments)]
396pub async fn build_full_context(
397 task: &ExternalTask,
398 backend: Option<&dyn ExternalBackend>,
399 project_dir: &Path,
400 default_branch: &str,
401 attempts: u32,
402 selected_skills: &[String],
403 store: &Option<Arc<TaskStore>>,
404 repo: &str,
405) -> TaskContext {
406 let (
407 task_context,
408 parent_context,
409 project_instructions,
410 skills_docs,
411 repo_tree,
412 git_diff,
413 issue_comments,
414 pr_review_context,
415 (_, memory),
416 ) = tokio::join!(
417 load_task_context(&task.id.0),
418 async {
419 if let Some(b) = backend {
420 build_parent_context(task, b, store, repo).await
421 } else {
422 String::new()
423 }
424 },
425 build_project_instructions(project_dir),
426 build_skills_docs(selected_skills),
427 build_repo_tree(project_dir),
428 async {
429 if attempts > 0 {
430 build_git_diff(project_dir, default_branch).await
431 } else {
432 String::new()
433 }
434 },
435 async {
436 if let Some(b) = backend {
437 fetch_issue_comments(b, &task.id.0, 10).await
438 } else {
439 String::new()
440 }
441 },
442 load_pr_review_context(&task.id.0, store, repo),
443 build_memory_context(&task.id.0, store, repo),
444 );
445
446 TaskContext {
447 task_context,
448 parent_context,
449 project_instructions,
450 skills_docs,
451 repo_tree,
452 git_diff,
453 issue_comments,
454 pr_review_context,
455 memory,
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[tokio::test]
464 async fn test_build_memory_context_empty() {
465 let (context, memory) = build_memory_context("nonexistent-task-12345", &None, "").await;
466 assert!(context.is_empty());
467 assert!(memory.is_empty());
468 }
469
470 #[tokio::test]
471 async fn test_build_project_instructions_empty_dir() {
472 let dir = std::env::temp_dir().join("orch_test_nonexistent");
473 let instructions = build_project_instructions(&dir).await;
474 assert!(instructions.is_empty());
475 }
476
477 #[cfg(unix)]
478 #[tokio::test]
479 async fn project_instructions_read_a_symlinked_file_once() {
480 let dir = tempfile::tempdir().unwrap();
481 std::fs::write(dir.path().join("AGENTS.md"), "agent rules").unwrap();
482 std::os::unix::fs::symlink("AGENTS.md", dir.path().join("CLAUDE.md")).unwrap();
483 std::fs::write(dir.path().join("README.md"), "readme").unwrap();
484
485 let instructions = build_project_instructions(dir.path()).await;
486 assert_eq!(instructions.matches("agent rules").count(), 1);
487 assert!(instructions.contains("## CLAUDE.md"));
488 assert!(!instructions.contains("## AGENTS.md"));
489 assert!(instructions.contains("readme"));
490 }
491
492 #[tokio::test]
493 async fn test_build_skills_docs_empty() {
494 let docs = build_skills_docs(&[]).await;
495 assert!(docs.is_empty());
496 }
497
498 #[test]
499 fn test_task_context_default_values() {
500 let ctx = TaskContext {
501 task_context: String::new(),
502 parent_context: String::new(),
503 project_instructions: String::new(),
504 skills_docs: String::new(),
505 repo_tree: String::new(),
506 git_diff: String::new(),
507 issue_comments: String::new(),
508 pr_review_context: String::new(),
509 memory: vec![],
510 };
511
512 assert!(ctx.task_context.is_empty());
513 assert!(ctx.memory.is_empty());
514 }
515}