Skip to content

Commit 0ea22fd

Browse files
committed
Add schedule command to set/clear query refresh schedules
1 parent ffd15a4 commit 0ea22fd

6 files changed

Lines changed: 250 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## [Unreleased]
44

55
### Features
6+
- `schedule <IDs> --interval <SECONDS> [--time HH:MM] [--day-of-week N]`: set a query's refresh schedule in the local YAML (run `deploy` to push to Redash)
7+
- `schedule <IDs> --clear`: remove the refresh schedule from the local YAML
68
- `discover --search`/`-q`: full-text search across all queries and dashboards (with `--limit`, default 50)
79
- `execute` now resolves dynamic date tokens client-side
810

‎CLAUDE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Redash CLI that gives Claude Code direct access to sql.telemetry.mozilla.org —
44

55
## Quick Reference
66

7-
**Commands**: `discover [--search TEXT] [--limit N]` `init` `fetch` `deploy` `execute` `data-sources` `archive` `unarchive` `dashboards`
7+
**Commands**: `discover [--search TEXT] [--limit N]` `init` `fetch` `deploy` `execute` `data-sources` `archive` `unarchive` `dashboards` `schedule`
88
**Env Vars**: `REDASH_API_KEY` (required), `REDASH_URL` (optional, defaults to sql.telemetry.mozilla.org)
99

1010
## Key Constraints
@@ -35,6 +35,7 @@ src/
3535
├── dynamic_dates.rs # Resolve d_* date/range tokens client-side (port of Redash frontend)
3636
├── datasources.rs # List/explore data sources
3737
├── archive.rs # Archive/unarchive queries
38+
├── schedule.rs # Set/clear query refresh schedules (local YAML only; deploy to push)
3839
└── dashboards.rs # Dashboard management
3940
```
4041

‎src/commands/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod dynamic_dates;
77
pub mod execute;
88
pub mod fetch;
99
pub mod init;
10+
pub mod schedule;
1011
pub mod update;
1112

1213
use anyhow::{Result, bail};

‎src/commands/schedule.rs‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#![allow(clippy::missing_errors_doc)]
2+
3+
use anyhow::{Context, Result, bail};
4+
use std::fs;
5+
use std::path::{Path, PathBuf};
6+
7+
use crate::models::{QueryMetadata, Schedule};
8+
9+
fn find_yaml_path_in(dir: &Path, query_id: u64) -> Result<Option<PathBuf>> {
10+
if !dir.exists() {
11+
return Ok(None);
12+
}
13+
for entry in fs::read_dir(dir).context("Failed to read queries directory")? {
14+
let entry = entry.context("Failed to read directory entry")?;
15+
let path = entry.path();
16+
if path.extension().is_some_and(|ext| ext == "yaml")
17+
&& let Some(filename) = path.file_name().and_then(|f| f.to_str())
18+
&& let Some(id_str) = filename.split('-').next()
19+
&& let Ok(id) = id_str.parse::<u64>()
20+
&& id == query_id
21+
{
22+
return Ok(Some(path));
23+
}
24+
}
25+
Ok(None)
26+
}
27+
28+
fn update_yaml_schedule(yaml_path: &Path, schedule: Option<Schedule>) -> Result<String> {
29+
let content =
30+
fs::read_to_string(yaml_path).context(format!("Failed to read {}", yaml_path.display()))?;
31+
let mut metadata: QueryMetadata = serde_yaml::from_str(&content)
32+
.context(format!("Failed to parse {}", yaml_path.display()))?;
33+
34+
let name = metadata.name.clone();
35+
metadata.schedule = schedule;
36+
37+
let yaml_content =
38+
serde_yaml::to_string(&metadata).context("Failed to serialize query metadata")?;
39+
fs::write(yaml_path, yaml_content)
40+
.context(format!("Failed to write {}", yaml_path.display()))?;
41+
42+
Ok(name)
43+
}
44+
45+
pub fn schedule(
46+
query_ids: &[u64],
47+
interval: Option<u64>,
48+
time: Option<&str>,
49+
day_of_week: Option<&str>,
50+
clear: bool,
51+
) -> Result<()> {
52+
if !clear && interval.is_none() {
53+
bail!(
54+
"Either --interval or --clear must be specified.\n\nExamples:\n stmo-cli schedule 123 456 --interval 86400 --time 07:15\n stmo-cli schedule 123 --clear"
55+
);
56+
}
57+
58+
let new_schedule = if clear {
59+
None
60+
} else {
61+
Some(Schedule {
62+
interval,
63+
time: time.map(str::to_owned),
64+
day_of_week: day_of_week.map(str::to_owned),
65+
until: None,
66+
})
67+
};
68+
69+
let queries_dir = Path::new("queries");
70+
let mut errors: Vec<(u64, anyhow::Error)> = Vec::new();
71+
let mut updated_count = 0;
72+
73+
for &query_id in query_ids {
74+
match find_yaml_path_in(queries_dir, query_id) {
75+
Err(e) => {
76+
eprintln!(" ✗ Failed to search for query {query_id}: {e}");
77+
errors.push((query_id, e));
78+
}
79+
Ok(None) => {
80+
let e = anyhow::anyhow!(
81+
"No local file found for query {query_id}. Run 'stmo-cli fetch {query_id}' first."
82+
);
83+
eprintln!(" ✗ {e}");
84+
errors.push((query_id, e));
85+
}
86+
Ok(Some(yaml_path)) => match update_yaml_schedule(&yaml_path, new_schedule.clone()) {
87+
Ok(name) => {
88+
let action = if clear {
89+
"Cleared schedule from"
90+
} else {
91+
"Set schedule on"
92+
};
93+
println!(" ✓ {action} query {query_id} - {name}");
94+
updated_count += 1;
95+
}
96+
Err(e) => {
97+
eprintln!(" ✗ Failed to update query {query_id}: {e}");
98+
errors.push((query_id, e));
99+
}
100+
},
101+
}
102+
}
103+
104+
println!("\n✓ Updated {updated_count}/{} queries", query_ids.len());
105+
println!("Run 'stmo-cli deploy' to push the schedule changes to Redash.");
106+
107+
if !errors.is_empty() {
108+
bail!("Failed to update {} queries", errors.len());
109+
}
110+
Ok(())
111+
}
112+
113+
#[cfg(test)]
114+
mod tests {
115+
use super::*;
116+
use std::fs;
117+
use tempfile::TempDir;
118+
119+
const SAMPLE_YAML: &str = "id: 121795\nname: test query\ndescription: null\ndata_source_id: 63\nschedule: null\noptions:\n parameters: []\nvisualizations: []\ntags: null\n";
120+
121+
fn create_test_query(dir: &Path, id: u64, yaml_content: &str) {
122+
let yaml_path = dir.join(format!("{id}-test-query.yaml"));
123+
fs::write(yaml_path, yaml_content).unwrap();
124+
let sql_path = dir.join(format!("{id}-test-query.sql"));
125+
fs::write(sql_path, "SELECT 1").unwrap();
126+
}
127+
128+
#[test]
129+
fn set_schedule_writes_yaml() {
130+
let temp_dir = TempDir::new().unwrap();
131+
let queries_dir = temp_dir.path();
132+
create_test_query(queries_dir, 121_795, SAMPLE_YAML);
133+
134+
let yaml_path = queries_dir.join("121795-test-query.yaml");
135+
update_yaml_schedule(
136+
&yaml_path,
137+
Some(Schedule {
138+
interval: Some(86400),
139+
time: Some("07:15".to_owned()),
140+
day_of_week: None,
141+
until: None,
142+
}),
143+
)
144+
.unwrap();
145+
146+
let content = fs::read_to_string(&yaml_path).unwrap();
147+
let metadata: QueryMetadata = serde_yaml::from_str(&content).unwrap();
148+
let s = metadata.schedule.unwrap();
149+
assert_eq!(s.interval, Some(86_400));
150+
assert_eq!(s.time, Some("07:15".to_owned()));
151+
assert_eq!(s.day_of_week, None);
152+
assert_eq!(s.until, None);
153+
}
154+
155+
#[test]
156+
fn clear_schedule_writes_null() {
157+
let temp_dir = TempDir::new().unwrap();
158+
let queries_dir = temp_dir.path();
159+
let yaml_with_schedule = "id: 121795\nname: test query\ndescription: null\ndata_source_id: 63\nschedule:\n interval: 86400\n time: '07:15'\n day_of_week: null\n until: null\noptions:\n parameters: []\nvisualizations: []\ntags: null\n";
160+
create_test_query(queries_dir, 121_795, yaml_with_schedule);
161+
162+
let yaml_path = queries_dir.join("121795-test-query.yaml");
163+
update_yaml_schedule(&yaml_path, None).unwrap();
164+
165+
let content = fs::read_to_string(&yaml_path).unwrap();
166+
let metadata: QueryMetadata = serde_yaml::from_str(&content).unwrap();
167+
assert!(metadata.schedule.is_none());
168+
}
169+
170+
#[test]
171+
fn find_yaml_finds_by_id_prefix() {
172+
let temp_dir = TempDir::new().unwrap();
173+
let queries_dir = temp_dir.path();
174+
create_test_query(queries_dir, 121_795, SAMPLE_YAML);
175+
176+
let found = find_yaml_path_in(queries_dir, 121_795).unwrap();
177+
assert!(found.is_some());
178+
179+
let not_found = find_yaml_path_in(queries_dir, 99_999).unwrap();
180+
assert!(not_found.is_none());
181+
}
182+
183+
#[test]
184+
fn find_yaml_returns_none_for_missing_dir() {
185+
let path = find_yaml_path_in(Path::new("/nonexistent/path"), 123).unwrap();
186+
assert!(path.is_none());
187+
}
188+
}

‎src/main.rs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,39 @@ enum Commands {
130130
command: DashboardCommands,
131131
},
132132

133+
#[command(
134+
about = "Set or clear a query's refresh schedule (updates local YAML; run 'deploy' to push to Redash)",
135+
long_about = "Set or clear a query's refresh schedule.\n\nUpdates the schedule field in each query's local YAML file. The change is not pushed to Redash until you run 'stmo-cli deploy'.\n\nExamples:\n stmo-cli schedule 123 456 --interval 86400 --time 07:15\n stmo-cli schedule 123 --clear"
136+
)]
137+
Schedule {
138+
#[arg(help = "Query IDs to update (e.g., 123 456 789)")]
139+
query_ids: Vec<u64>,
140+
141+
#[arg(
142+
long,
143+
help = "Refresh interval in seconds (e.g., 86400 for daily)",
144+
conflicts_with = "clear"
145+
)]
146+
interval: Option<u64>,
147+
148+
#[arg(
149+
long,
150+
help = "Time of day for the refresh in HH:MM format (e.g., 07:15)",
151+
requires = "interval"
152+
)]
153+
time: Option<String>,
154+
155+
#[arg(
156+
long,
157+
help = "Day of week for the refresh (0=Sunday through 6=Saturday)",
158+
requires = "interval"
159+
)]
160+
day_of_week: Option<String>,
161+
162+
#[arg(long, help = "Clear the refresh schedule", conflicts_with = "interval")]
163+
clear: bool,
164+
},
165+
133166
#[command(about = "Update stmo-cli to the latest version")]
134167
Update,
135168
}
@@ -174,6 +207,7 @@ enum DashboardCommands {
174207
},
175208
}
176209

210+
#[allow(clippy::too_many_lines)]
177211
async fn run_command(client: RedashClient, command: Commands) -> Result<()> {
178212
match command {
179213
Commands::Discover { search, limit } => {
@@ -249,6 +283,26 @@ async fn run_command(client: RedashClient, command: Commands) -> Result<()> {
249283
}
250284
commands::archive::unarchive(&client, query_ids).await?;
251285
}
286+
Commands::Schedule {
287+
query_ids,
288+
interval,
289+
time,
290+
day_of_week,
291+
clear,
292+
} => {
293+
if query_ids.is_empty() {
294+
anyhow::bail!(
295+
"No query IDs specified. Provide query IDs to update.\n\nExamples:\n stmo-cli schedule 123 456 --interval 86400 --time 07:15\n stmo-cli schedule 123 --clear"
296+
);
297+
}
298+
commands::schedule::schedule(
299+
&query_ids,
300+
interval,
301+
time.as_deref(),
302+
day_of_week.as_deref(),
303+
clear,
304+
)?;
305+
}
252306
Commands::Dashboards { command } => match command {
253307
DashboardCommands::Discover => commands::dashboards::discover(&client).await?,
254308
DashboardCommands::Fetch { slugs } => {
@@ -285,6 +339,7 @@ discover [--search TEXT] [--limit N] | fetch [IDs] [--all] | deploy [IDs] [--all
285339
data-sources [ID] [--schema] [--refresh] | archive IDs | archive --cleanup | unarchive IDs | init | update
286340
dashboards discover|fetch SLUGS|deploy SLUGS [--all]|archive SLUGS|unarchive SLUGS
287341
342+
schedule IDs --interval SECS [--time HH:MM] [--day-of-week N] | schedule IDs --clear (writes YAML; run deploy to push)
288343
deploy: uses git diff by default; --all required outside a git repo
289344
archive IDs: archives on server + deletes local | archive --cleanup: deletes local only for already-archived (does NOT archive on server)
290345
dashboards: addressed by slug not ID; only favorited dashboards appear in dashboards discover

‎templates/init/CLAUDE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This repository contains version-controlled Redash queries and dashboards manage
55
## Quick Reference
66

77
**Install**: `cargo install stmo-cli`
8-
**Commands**: `discover [--search TEXT] [--limit N]` `fetch` `deploy` `execute` `data-sources` `archive` `unarchive` `dashboards`
8+
**Commands**: `discover [--search TEXT] [--limit N]` `fetch` `deploy` `execute` `data-sources` `archive` `unarchive` `dashboards` `schedule`
99
**File Naming**: `queries/{id}-{slug}.sql` + `queries/{id}-{slug}.yaml`, `dashboards/{id}-{slug}.yaml`
1010
**Env Vars**: `REDASH_API_KEY` (required), `REDASH_URL` (optional, defaults to sql.telemetry.mozilla.org)
1111

@@ -34,6 +34,7 @@ To restore: `stmo-cli unarchive <id> && stmo-cli fetch <id>`
3434
**data-sources**: List sources, `<id> --schema` for tables
3535
**archive**: Archive queries + delete local (`<ids>` or `--cleanup`)
3636
**unarchive**: Restore archived queries (`<ids>`)
37+
**schedule**: Set or clear a query's refresh schedule in the local YAML (`--interval SECS [--time HH:MM] [--day-of-week N]` or `--clear`); run `deploy` afterwards to push to Redash
3738

3839
### Dashboards
3940
**dashboards discover**: List your favorite dashboards (IDs + names + slugs)

0 commit comments

Comments
 (0)