Skip to main content

ito_core/ralph/
task_sources.rs

1use crate::errors::{CoreError, CoreResult};
2use serde::Deserialize;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6/// A single pending task resolved from an external Ralph task source.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum RalphTaskSource {
9    /// A pending markdown checkbox task.
10    Markdown {
11        /// Source markdown file path.
12        path: PathBuf,
13        /// Zero-based line index of the checkbox entry.
14        line_index: usize,
15        /// Human-readable task text.
16        task: String,
17    },
18    /// A pending YAML task entry.
19    Yaml {
20        /// Source YAML file path.
21        path: PathBuf,
22        /// Zero-based task index within the parsed `tasks` list.
23        index: usize,
24        /// Human-readable task title.
25        task: String,
26        /// Optional parallel grouping key.
27        parallel_group: u32,
28    },
29    /// A pending GitHub issue task.
30    Github {
31        /// Repository in `owner/repo` form.
32        repo: String,
33        /// GitHub issue number.
34        issue_number: u64,
35        /// Human-readable task title.
36        task: String,
37    },
38}
39
40impl RalphTaskSource {
41    /// Build a harness prompt block for this external task source.
42    pub fn build_prompt(&self, base_prompt: &str) -> String {
43        let task_block = match self {
44            Self::Markdown { path, task, .. } => format!(
45                "## External Task Source\n- Type: markdown\n- Path: {}\n\n## Pending Task\n{}",
46                path.display(),
47                task
48            ),
49            Self::Yaml { path, task, .. } => format!(
50                "## External Task Source\n- Type: yaml\n- Path: {}\n\n## Pending Task\n{}",
51                path.display(),
52                task
53            ),
54            Self::Github {
55                repo,
56                issue_number,
57                task,
58            } => format!(
59                "## External Task Source\n- Type: github\n- Repo: {}\n- Issue: #{}\n\n## Pending Task\n{}",
60                repo, issue_number, task
61            ),
62        };
63
64        if base_prompt.trim().is_empty() {
65            return task_block;
66        }
67
68        format!("{task_block}\n\n---\n\n{base_prompt}")
69    }
70}
71
72/// Resolve unchecked markdown checkbox tasks from a PRD-style markdown file.
73pub fn resolve_markdown_task_sources(path: &Path) -> CoreResult<Vec<RalphTaskSource>> {
74    let contents = std::fs::read_to_string(path).map_err(|err| {
75        CoreError::io(
76            format!("Failed to read markdown task file {}", path.display()),
77            err,
78        )
79    })?;
80    let mut tasks = Vec::new();
81
82    for (line_index, line) in contents.lines().enumerate() {
83        let Some(task) = pending_markdown_task(line) else {
84            continue;
85        };
86        tasks.push(RalphTaskSource::Markdown {
87            path: path.to_path_buf(),
88            line_index,
89            task,
90        });
91    }
92
93    Ok(tasks)
94}
95
96/// Resolve incomplete YAML tasks from a task list file.
97pub fn resolve_yaml_task_sources(path: &Path) -> CoreResult<Vec<RalphTaskSource>> {
98    let contents = std::fs::read_to_string(path).map_err(|err| {
99        CoreError::io(
100            format!("Failed to read YAML task file {}", path.display()),
101            err,
102        )
103    })?;
104    let parsed: RalphYamlTasks = serde_yaml::from_str(&contents).map_err(|err| {
105        CoreError::serde(
106            format!("Failed to parse YAML task file {}", path.display()),
107            err.to_string(),
108        )
109    })?;
110
111    let mut tasks = Vec::new();
112    for (index, task) in parsed.tasks.into_iter().enumerate() {
113        if task.completed.unwrap_or(false) {
114            continue;
115        }
116        tasks.push(RalphTaskSource::Yaml {
117            path: path.to_path_buf(),
118            index,
119            task: task.title,
120            parallel_group: task.parallel_group.unwrap_or(0),
121        });
122    }
123
124    Ok(tasks)
125}
126
127/// Resolve open GitHub issues into Ralph task sources.
128pub fn resolve_github_task_sources(
129    repo: &str,
130    label: Option<&str>,
131) -> CoreResult<Vec<RalphTaskSource>> {
132    let mut command = Command::new("gh");
133    command
134        .arg("issue")
135        .arg("list")
136        .arg("--repo")
137        .arg(repo)
138        .arg("--state")
139        .arg("open")
140        .arg("--json")
141        .arg("number,title");
142    if let Some(label) = label {
143        command.arg("--label").arg(label);
144    }
145
146    let output = command.output().map_err(|err| {
147        CoreError::process(format!(
148            "Failed to run `gh issue list` for repository {repo}: {err}"
149        ))
150    })?;
151    if !output.status.success() {
152        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
153        return Err(CoreError::process(format!(
154            "`gh issue list` failed for repository {repo}: {stderr}"
155        )));
156    }
157
158    let issues: Vec<GitHubIssue> = serde_json::from_slice(&output.stdout).map_err(|err| {
159        CoreError::serde(
160            format!("Failed to parse `gh issue list` output for repository {repo}"),
161            err.to_string(),
162        )
163    })?;
164
165    Ok(issues
166        .into_iter()
167        .map(|issue| RalphTaskSource::Github {
168            repo: repo.to_string(),
169            issue_number: issue.number,
170            task: issue.title,
171        })
172        .collect())
173}
174
175fn pending_markdown_task(line: &str) -> Option<String> {
176    let trimmed = line.trim_start();
177    let task = trimmed
178        .strip_prefix("- [ ] ")
179        .or_else(|| trimmed.strip_prefix("* [ ] "))?;
180    let task = task.trim();
181    if task.is_empty() {
182        return None;
183    }
184    Some(task.to_string())
185}
186
187#[derive(Debug, Deserialize)]
188struct RalphYamlTasks {
189    tasks: Vec<RalphYamlTask>,
190}
191
192#[derive(Debug, Deserialize)]
193struct RalphYamlTask {
194    title: String,
195    completed: Option<bool>,
196    parallel_group: Option<u32>,
197}
198
199#[derive(Debug, Deserialize)]
200struct GitHubIssue {
201    number: u64,
202    title: String,
203}