1use crate::backends::ExternalBackend;
2use crate::backends::github::GitHubBackend;
3use crate::cli;
4use crate::config;
5use crate::engine::jobs::{self, Job, TaskTemplate};
6use crate::store::JobState;
7use anyhow::Context;
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::Arc;
11
12pub async fn list() -> anyhow::Result<()> {
14 let path = jobs::resolve_jobs_path();
15 let jobs = jobs::load_jobs(&path)?;
16
17 if jobs.is_empty() {
18 println!("No jobs configured.");
19 println!("Add one with: orch job add \"0 9 * * *\" \"Daily review\"");
20 return Ok(());
21 }
22
23 let store = cli::init_store().await.ok().map(Arc::new);
25 let repo = config::get_current_repo().unwrap_or_default();
26 let states: HashMap<String, JobState> = if let Some(ref s) = store {
27 s.list_job_states(&repo)
28 .await
29 .unwrap_or_default()
30 .into_iter()
31 .map(|st| (st.job_id.clone(), st))
32 .collect()
33 } else {
34 HashMap::new()
35 };
36
37 println!(
38 "{:<20} {:<8} {:<20} {:<10} TITLE/COMMAND",
39 "ID", "TYPE", "SCHEDULE", "ENABLED"
40 );
41 println!("{}", "-".repeat(80));
42
43 for job in &jobs {
44 let desc = match job.r#type.as_str() {
45 "bash" => job.command.as_deref().unwrap_or(""),
46 _ => job.task.as_ref().map_or("", |t| t.title.as_str()),
47 };
48
49 println!(
50 "{:<20} {:<8} {:<20} {:<10} {}",
51 job.id,
52 job.r#type,
53 job.schedule,
54 if job.enabled { "yes" } else { "no" },
55 desc,
56 );
57
58 if let Some(state) = states.get(&job.id)
59 && let Some(ref last_run) = state.last_run
60 {
61 println!(
62 " Last run: {} (status: {})",
63 last_run,
64 state.last_task_status.as_deref().unwrap_or("unknown")
65 );
66 }
67 }
68
69 Ok(())
70}
71
72pub fn add(
74 schedule: &str,
75 title: &str,
76 body: Option<&str>,
77 job_type: &str,
78 command: Option<&str>,
79) -> anyhow::Result<()> {
80 let path = jobs::resolve_jobs_path();
81 let mut jobs = jobs::load_jobs(&path)?;
82
83 let id = title
85 .to_lowercase()
86 .chars()
87 .map(|c| if c.is_alphanumeric() { c } else { '-' })
88 .collect::<String>()
89 .trim_matches('-')
90 .to_string();
91
92 if jobs.iter().any(|j| j.id == id) {
94 anyhow::bail!("job with id '{id}' already exists");
95 }
96
97 let job = Job {
98 id: id.clone(),
99 r#type: job_type.to_string(),
100 schedule: schedule.to_string(),
101 task: if job_type == "task" {
102 Some(TaskTemplate {
103 title: title.to_string(),
104 body: body.unwrap_or("").to_string(),
105 prompt: None,
106 labels: vec![],
107 agent: None,
108 })
109 } else {
110 None
111 },
112 command: command.map(String::from),
113 dir: None,
114 enabled: true,
115 external: true,
116 notify: false,
117 notify_target: None,
118 prompt_file: None,
119 };
120
121 jobs.push(job);
122 jobs::save_jobs(&path, &jobs)?;
123
124 println!("Added job '{id}' (schedule: {schedule})");
125 Ok(())
126}
127
128pub fn remove(id: &str) -> anyhow::Result<()> {
130 let path = jobs::resolve_jobs_path();
131 let mut jobs = jobs::load_jobs(&path)?;
132
133 let initial_len = jobs.len();
134 jobs.retain(|j| j.id != id);
135
136 if jobs.len() == initial_len {
137 anyhow::bail!("job '{id}' not found");
138 }
139
140 jobs::save_jobs(&path, &jobs)?;
141 println!("Removed job '{id}'");
142 Ok(())
143}
144
145pub fn enable(id: &str) -> anyhow::Result<()> {
147 toggle_job(id, true)
148}
149
150pub fn disable(id: &str) -> anyhow::Result<()> {
152 toggle_job(id, false)
153}
154
155fn toggle_job(id: &str, enabled: bool) -> anyhow::Result<()> {
156 let path = jobs::resolve_jobs_path();
157 let mut all_jobs = jobs::load_jobs(&path)?;
158
159 let job = all_jobs
160 .iter_mut()
161 .find(|j| j.id == id)
162 .with_context(|| format!("job '{id}' not found"))?;
163
164 if let Some(ref prompt_path) = job.prompt_file.clone() {
168 jobs::set_frontmatter_enabled(prompt_path, enabled)?;
169 } else {
170 job.enabled = enabled;
171 jobs::save_jobs(&path, &all_jobs)?;
172 }
173
174 println!(
175 "Job '{}' {}",
176 id,
177 if enabled { "enabled" } else { "disabled" }
178 );
179 Ok(())
180}
181
182pub async fn run(job_id: &str, project: Option<&str>) -> anyhow::Result<()> {
187 let mut resolved: Option<(String, jobs::Job, PathBuf)> = None;
189
190 if project.is_none() {
191 if let Ok(cwd_repo) = config::get_current_repo() {
193 let jobs_path = jobs::resolve_jobs_path();
194 if let Ok(all_jobs) = jobs::load_jobs(&jobs_path)
195 && let Some(job) = all_jobs.into_iter().find(|j| j.id == job_id)
196 {
197 let base_dir = jobs_path
198 .parent()
199 .map_or_else(|| PathBuf::from("."), PathBuf::from);
200 resolved = Some((cwd_repo, job, base_dir));
201 }
202 }
203 }
204
205 if resolved.is_none() {
207 let mut matches: Vec<(String, jobs::Job, PathBuf)> = Vec::new();
208
209 let projects = config::get_projects_with_paths().unwrap_or_default();
210 for (repo, dir) in &projects {
211 if let Some(filter) = project
212 && !repo.contains(filter)
213 {
214 continue;
215 }
216 let orch_yml = dir.join(".orch.yml");
217 if let Ok(all_jobs) = jobs::load_jobs(&orch_yml) {
218 for job in all_jobs {
219 if job.id == job_id {
220 matches.push((repo.clone(), job, dir.clone()));
221 }
222 }
223 }
224 }
225
226 match matches.len() {
227 0 => anyhow::bail!("job '{job_id}' not found in any project"),
228 1 => resolved = matches.pop(),
229 _ => {
230 let repos: Vec<&str> = matches.iter().map(|(r, _, _)| r.as_str()).collect();
231 anyhow::bail!(
232 "job '{}' exists in multiple projects: {}\nUse --project to specify which one, e.g.: orch job run {} --project {}",
233 job_id,
234 repos.join(", "),
235 job_id,
236 repos[0],
237 );
238 }
239 }
240 }
241
242 let Some((repo, job, base_dir)) = resolved else {
243 anyhow::bail!("job '{job_id}' not found")
244 };
245
246 if !job.enabled {
247 println!("Warning: job '{job_id}' is disabled, running anyway");
248 }
249
250 let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
251 let store = cli::init_store().await.ok().map(Arc::new);
252
253 let mut state = if let Some(ref s) = store {
255 s.get_job_state(&repo, job_id)
256 .await?
257 .unwrap_or_else(|| JobState::new(&repo, job_id))
258 } else {
259 JobState::new(&repo, job_id)
260 };
261
262 println!("Running job '{}' ({}) in {}", job_id, job.r#type, repo);
263
264 state.last_run = Some(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string());
269 if let Some(ref s) = store
270 && let Err(e) = s.upsert_job_state(&state).await
271 {
272 tracing::error!(
273 job_id = job_id,
274 ?e,
275 "failed to persist job state before execution",
276 );
277 }
278
279 jobs::execute_job(
281 &job,
282 &mut state,
283 &backend,
284 store.as_ref(),
285 &repo,
286 None,
287 &base_dir,
288 )
289 .await;
290
291 let status = state.last_task_status.as_deref().unwrap_or("unknown");
292 if let Some(ref task_id) = state.active_task_id {
293 println!("Done (status: {status}, task: {task_id})");
294 } else {
295 println!("Done (status: {status})");
296 }
297
298 Ok(())
299}
300
301pub async fn tick() -> anyhow::Result<()> {
303 let repo =
304 config::get_current_repo().context("'repo' not set — ensure .orch.yml has gh.repo")?;
305 let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
306 let store = cli::init_store().await.ok().map(Arc::new);
307
308 let path = jobs::resolve_jobs_path();
309 jobs::tick(&path, &backend, store.as_ref(), &repo, None).await?;
310
311 println!("Job tick completed");
312 Ok(())
313}