use crate::backends::{ExternalBackend, ExternalId, ExternalTask};
use crate::cmd::CommandErrorContext;
use crate::store;
use crate::store::TaskStore;
use futures::future::join_all;
use std::path::Path;
use std::sync::Arc;
use tokio::process::Command;
#[derive(Default)]
pub struct TaskContext {
pub task_context: String,
pub parent_context: String,
pub project_instructions: String,
pub skills_docs: String,
pub repo_tree: String,
pub git_diff: String,
pub issue_comments: String,
pub pr_review_context: String,
pub memory: Vec<crate::store::MemoryEntry>,
}
pub async fn load_task_context(task_id: &str) -> String {
let contexts_dir = crate::home::contexts_dir().unwrap_or_default();
let path = contexts_dir.join(format!("task-{task_id}.md"));
tokio::fs::read_to_string(&path).await.unwrap_or_default()
}
pub async fn build_parent_context(
task: &ExternalTask,
backend: &dyn ExternalBackend,
store: &Option<Arc<TaskStore>>,
repo: &str,
) -> String {
let parent_id = match store::opt_store_get_task(store, repo, &task.id.0)
.await
.and_then(|t| t.parent_id)
{
Some(id) => id.to_string(),
None => return String::new(),
};
let mut ctx = String::new();
let parent = match backend.get_task(&ExternalId(parent_id.clone())).await {
Ok(t) => t,
Err(_) => return String::new(),
};
ctx.push_str(&format!(
"## Parent Task #{}\n\n**Title**: {}\n\n{}\n\n",
parent.id.0, parent.title, parent.body
));
if let Ok(siblings) = backend.get_sub_issues(&ExternalId(parent_id)).await {
let siblings: Vec<ExternalId> = siblings.into_iter().filter(|s| s.0 != task.id.0).collect();
if !siblings.is_empty() {
ctx.push_str("## Sibling Tasks\n\n");
let task_futs = siblings.iter().map(|id| backend.get_task(id));
let task_results = join_all(task_futs).await;
let store_futs = siblings
.iter()
.map(|id| store::opt_store_get_task(store, repo, &id.0));
let store_results = join_all(store_futs).await;
for (i, maybe_task_res) in task_results.into_iter().enumerate() {
if let Ok(sib) = maybe_task_res {
let status = sib
.labels
.iter()
.find(|l| l.starts_with("status:"))
.map(|s| s.replace("status:", ""))
.unwrap_or_else(|| "unknown".to_string());
ctx.push_str(&format!("- #{} [{}]: {}\n", sib.id.0, status, sib.title));
if let Some(Some(task_rec)) = store_results.get(i) {
let summary = &task_rec.summary;
if !summary.is_empty() {
ctx.push_str(&format!(" Summary: {}\n", summary));
}
}
}
}
ctx.push('\n');
}
}
ctx
}
pub async fn build_project_instructions(project_dir: &Path) -> String {
let mut instructions = String::new();
for filename in &["CLAUDE.md", "AGENTS.md", "README.md"] {
let path = project_dir.join(filename);
if let Ok(content) = tokio::fs::read_to_string(&path).await {
if !content.is_empty() {
instructions.push_str(&format!("## {filename}\n\n{content}\n\n"));
}
}
}
instructions
}
pub async fn build_skills_docs(selected_skills: &[String]) -> String {
if selected_skills.is_empty() {
return String::new();
}
let mut docs = String::new();
let skills_dirs = [
dirs::home_dir()
.unwrap_or_default()
.join(".claude")
.join("skills"),
crate::home::skills_dir().unwrap_or_default(),
];
for skill in selected_skills {
for dir in &skills_dirs {
let skill_file = dir.join(skill).join("SKILL.md");
if let Ok(content) = tokio::fs::read_to_string(&skill_file).await {
docs.push_str(&format!("## Skill: {skill}\n\n{content}\n\n"));
break;
}
}
}
docs
}
pub async fn build_repo_tree(project_dir: &Path) -> String {
let output = Command::new("git")
.args(["ls-files"])
.current_dir(project_dir)
.output_with_context()
.await;
match output {
Ok(o) if o.status.success() => {
let full = String::from_utf8_lossy(&o.stdout);
let all_lines: Vec<&str> = full.lines().collect();
let total = all_lines.len();
if total > 200 {
format!(
"{}\n... (truncated, {} total files)",
all_lines[..200].join("\n"),
total
)
} else {
all_lines.join("\n")
}
}
_ => String::new(),
}
}
pub async fn build_git_diff(project_dir: &Path, default_branch: &str) -> String {
let output = Command::new("git")
.args(["diff", &format!("origin/{default_branch}...HEAD")])
.current_dir(project_dir)
.output_with_context()
.await;
match output {
Ok(o) if o.status.success() => {
let diff = String::from_utf8_lossy(&o.stdout);
if diff.len() > 10000 {
let mut boundary = 10000;
while !diff.is_char_boundary(boundary) {
boundary -= 1;
}
format!("{}...\n(diff truncated at 10000 chars)", &diff[..boundary])
} else {
diff.to_string()
}
}
_ => String::new(),
}
}
pub async fn build_git_log(project_dir: &Path, default_branch: &str) -> String {
let output = Command::new("git")
.args([
"log",
"--oneline",
&format!("origin/{default_branch}..HEAD"),
])
.current_dir(project_dir)
.output_with_context()
.await;
match output {
Ok(o) if o.status.success() => {
let log = String::from_utf8_lossy(&o.stdout);
let all_lines: Vec<&str> = log.lines().collect();
let total = all_lines.len();
if total > 100 {
format!(
"{}\n... (truncated, {} total commits)",
all_lines[..100].join("\n"),
total
)
} else {
all_lines.join("\n")
}
}
_ => String::new(),
}
}
fn is_bot_comment(mention: &crate::backends::Mention) -> bool {
if mention.author.ends_with("[bot]") {
return true;
}
if mention.body.starts_with("## Automated Review") {
return true;
}
false
}
pub async fn fetch_issue_comments(
backend: &dyn ExternalBackend,
task_id: &str,
limit: usize,
) -> String {
let id = crate::backends::ExternalId(task_id.to_string());
let all_comments = match backend.list_issue_comments(&id).await {
Ok(c) => c,
Err(e) => {
tracing::debug!(task_id, error = %e, "failed to fetch issue comments");
return String::new();
}
};
let human_comments: Vec<_> = all_comments
.into_iter()
.filter(|c| !is_bot_comment(c))
.collect();
let start = human_comments.len().saturating_sub(limit);
let mut formatted = String::new();
for comment in &human_comments[start..] {
formatted.push_str(&format!(
"**@{}** ({}):\n{}\n\n",
comment.author, comment.created_at, comment.body
));
}
formatted
}
pub async fn build_memory_context(
task_id: &str,
store: &Option<Arc<TaskStore>>,
repo: &str,
) -> (String, Vec<crate::store::MemoryEntry>) {
const MAX_MEMORY_ENTRIES: usize = 3;
let memory = match store::get_recent_memory_result(store, repo, task_id, MAX_MEMORY_ENTRIES)
.await
{
Ok(Some(entries)) => entries,
Ok(None) => Vec::new(),
Err(e) => {
tracing::warn!(task_id, error = %e, "build_memory_context: failed to read memory from store — proceeding without prior learnings");
Vec::new()
}
};
if memory.is_empty() {
return (String::new(), vec![]);
}
let mut context = String::new();
context.push_str("## Previous Attempts Memory\n\n");
context.push_str("Learnings from previous task attempts:\n\n");
for entry in &memory {
context.push_str(&format!(
"### Attempt #{} (Agent: {})",
entry.attempt, entry.agent
));
if let Some(ref model) = entry.model {
context.push_str(&format!(", Model: {}", model));
}
context.push('\n');
if !entry.approach.is_empty() {
context.push_str(&format!("**Approach**: {}\n", entry.approach));
}
if !entry.learnings.is_empty() {
context.push_str("**Key Learnings**:\n");
for learning in &entry.learnings {
context.push_str(&format!("- {}\n", learning));
}
}
if let Some(ref error) = entry.error {
context.push_str(&format!("**Error**: {}\n", error));
}
if !entry.files_modified.is_empty() {
context.push_str(&format!(
"**Files Modified**: {}\n",
entry.files_modified.join(", ")
));
}
context.push('\n');
}
(context, memory)
}
pub async fn load_pr_review_context(
task_id: &str,
store: &Option<Arc<TaskStore>>,
repo: &str,
) -> String {
store::opt_store_get_task(store, repo, task_id)
.await
.map(|t| t.pr_review_context)
.unwrap_or_default()
}
#[allow(clippy::too_many_arguments)]
pub async fn build_full_context(
task: &ExternalTask,
backend: Option<&dyn ExternalBackend>,
project_dir: &Path,
default_branch: &str,
attempts: u32,
selected_skills: &[String],
store: &Option<Arc<TaskStore>>,
repo: &str,
) -> TaskContext {
let (
task_context,
parent_context,
project_instructions,
skills_docs,
repo_tree,
git_diff,
issue_comments,
pr_review_context,
(_, memory),
) = tokio::join!(
load_task_context(&task.id.0),
async {
if let Some(b) = backend {
build_parent_context(task, b, store, repo).await
} else {
String::new()
}
},
build_project_instructions(project_dir),
build_skills_docs(selected_skills),
build_repo_tree(project_dir),
async {
if attempts > 0 {
build_git_diff(project_dir, default_branch).await
} else {
String::new()
}
},
async {
if let Some(b) = backend {
fetch_issue_comments(b, &task.id.0, 10).await
} else {
String::new()
}
},
load_pr_review_context(&task.id.0, store, repo),
build_memory_context(&task.id.0, store, repo),
);
TaskContext {
task_context,
parent_context,
project_instructions,
skills_docs,
repo_tree,
git_diff,
issue_comments,
pr_review_context,
memory,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_build_memory_context_empty() {
let (context, memory) = build_memory_context("nonexistent-task-12345", &None, "").await;
assert!(context.is_empty());
assert!(memory.is_empty());
}
#[tokio::test]
async fn test_build_project_instructions_empty_dir() {
let dir = std::env::temp_dir().join("orch_test_nonexistent");
let instructions = build_project_instructions(&dir).await;
assert!(instructions.is_empty());
}
#[tokio::test]
async fn test_build_skills_docs_empty() {
let docs = build_skills_docs(&[]).await;
assert!(docs.is_empty());
}
#[test]
fn test_task_context_default_values() {
let ctx = TaskContext {
task_context: String::new(),
parent_context: String::new(),
project_instructions: String::new(),
skills_docs: String::new(),
repo_tree: String::new(),
git_diff: String::new(),
issue_comments: String::new(),
pr_review_context: String::new(),
memory: vec![],
};
assert!(ctx.task_context.is_empty());
assert!(ctx.memory.is_empty());
}
}