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