Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## [Unreleased]

### Features
- `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)
- `schedule <IDs> --clear`: remove the refresh schedule from the local YAML
- `discover --search`/`-q`: full-text search across all queries and dashboards (with `--limit`, default 50)
- `execute` now resolves dynamic date tokens client-side

Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Redash CLI that gives Claude Code direct access to sql.telemetry.mozilla.org —

## Quick Reference

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

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

Expand Down
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod dynamic_dates;
pub mod execute;
pub mod fetch;
pub mod init;
pub mod schedule;
pub mod update;

use anyhow::{Result, bail};
Expand Down
188 changes: 188 additions & 0 deletions src/commands/schedule.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#![allow(clippy::missing_errors_doc)]

use anyhow::{Context, Result, bail};
use std::fs;
use std::path::{Path, PathBuf};

use crate::models::{QueryMetadata, Schedule};

fn find_yaml_path_in(dir: &Path, query_id: u64) -> Result<Option<PathBuf>> {
if !dir.exists() {
return Ok(None);
}
for entry in fs::read_dir(dir).context("Failed to read queries directory")? {
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "yaml")
&& let Some(filename) = path.file_name().and_then(|f| f.to_str())
&& let Some(id_str) = filename.split('-').next()
&& let Ok(id) = id_str.parse::<u64>()
&& id == query_id
{
return Ok(Some(path));
}
}
Ok(None)
}

fn update_yaml_schedule(yaml_path: &Path, schedule: Option<Schedule>) -> Result<String> {
let content =
fs::read_to_string(yaml_path).context(format!("Failed to read {}", yaml_path.display()))?;
let mut metadata: QueryMetadata = serde_yaml::from_str(&content)
.context(format!("Failed to parse {}", yaml_path.display()))?;

let name = metadata.name.clone();
metadata.schedule = schedule;

let yaml_content =
serde_yaml::to_string(&metadata).context("Failed to serialize query metadata")?;
fs::write(yaml_path, yaml_content)
.context(format!("Failed to write {}", yaml_path.display()))?;

Ok(name)
}

pub fn schedule(
query_ids: &[u64],
interval: Option<u64>,
time: Option<&str>,
day_of_week: Option<&str>,
clear: bool,
) -> Result<()> {
if !clear && interval.is_none() {
bail!(
"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"
);
}

let new_schedule = if clear {
None
} else {
Some(Schedule {
interval,
time: time.map(str::to_owned),
day_of_week: day_of_week.map(str::to_owned),
until: None,
})
};

let queries_dir = Path::new("queries");
let mut errors: Vec<(u64, anyhow::Error)> = Vec::new();
let mut updated_count = 0;

for &query_id in query_ids {
match find_yaml_path_in(queries_dir, query_id) {
Err(e) => {
eprintln!(" ✗ Failed to search for query {query_id}: {e}");
errors.push((query_id, e));
}
Ok(None) => {
let e = anyhow::anyhow!(
"No local file found for query {query_id}. Run 'stmo-cli fetch {query_id}' first."
);
eprintln!(" ✗ {e}");
errors.push((query_id, e));
}
Ok(Some(yaml_path)) => match update_yaml_schedule(&yaml_path, new_schedule.clone()) {
Ok(name) => {
let action = if clear {
"Cleared schedule from"
} else {
"Set schedule on"
};
println!(" ✓ {action} query {query_id} - {name}");
updated_count += 1;
}
Err(e) => {
eprintln!(" ✗ Failed to update query {query_id}: {e}");
errors.push((query_id, e));
}
},
}
}

println!("\n✓ Updated {updated_count}/{} queries", query_ids.len());
println!("Run 'stmo-cli deploy' to push the schedule changes to Redash.");

if !errors.is_empty() {
bail!("Failed to update {} queries", errors.len());
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;

const SAMPLE_YAML: &str = "id: 121795\nname: test query\ndescription: null\ndata_source_id: 63\nschedule: null\noptions:\n parameters: []\nvisualizations: []\ntags: null\n";

fn create_test_query(dir: &Path, id: u64, yaml_content: &str) {
let yaml_path = dir.join(format!("{id}-test-query.yaml"));
fs::write(yaml_path, yaml_content).unwrap();
let sql_path = dir.join(format!("{id}-test-query.sql"));
fs::write(sql_path, "SELECT 1").unwrap();
}

#[test]
fn set_schedule_writes_yaml() {
let temp_dir = TempDir::new().unwrap();
let queries_dir = temp_dir.path();
create_test_query(queries_dir, 121_795, SAMPLE_YAML);

let yaml_path = queries_dir.join("121795-test-query.yaml");
update_yaml_schedule(
&yaml_path,
Some(Schedule {
interval: Some(86400),
time: Some("07:15".to_owned()),
day_of_week: None,
until: None,
}),
)
.unwrap();

let content = fs::read_to_string(&yaml_path).unwrap();
let metadata: QueryMetadata = serde_yaml::from_str(&content).unwrap();
let s = metadata.schedule.unwrap();
assert_eq!(s.interval, Some(86_400));
assert_eq!(s.time, Some("07:15".to_owned()));
assert_eq!(s.day_of_week, None);
assert_eq!(s.until, None);
}

#[test]
fn clear_schedule_writes_null() {
let temp_dir = TempDir::new().unwrap();
let queries_dir = temp_dir.path();
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";
create_test_query(queries_dir, 121_795, yaml_with_schedule);

let yaml_path = queries_dir.join("121795-test-query.yaml");
update_yaml_schedule(&yaml_path, None).unwrap();

let content = fs::read_to_string(&yaml_path).unwrap();
let metadata: QueryMetadata = serde_yaml::from_str(&content).unwrap();
assert!(metadata.schedule.is_none());
}

#[test]
fn find_yaml_finds_by_id_prefix() {
let temp_dir = TempDir::new().unwrap();
let queries_dir = temp_dir.path();
create_test_query(queries_dir, 121_795, SAMPLE_YAML);

let found = find_yaml_path_in(queries_dir, 121_795).unwrap();
assert!(found.is_some());

let not_found = find_yaml_path_in(queries_dir, 99_999).unwrap();
assert!(not_found.is_none());
}

#[test]
fn find_yaml_returns_none_for_missing_dir() {
let path = find_yaml_path_in(Path::new("/nonexistent/path"), 123).unwrap();
assert!(path.is_none());
}
}
55 changes: 55 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,39 @@ enum Commands {
command: DashboardCommands,
},

#[command(
about = "Set or clear a query's refresh schedule (updates local YAML; run 'deploy' to push to Redash)",
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"
)]
Schedule {
#[arg(help = "Query IDs to update (e.g., 123 456 789)")]
query_ids: Vec<u64>,

#[arg(
long,
help = "Refresh interval in seconds (e.g., 86400 for daily)",
conflicts_with = "clear"
)]
interval: Option<u64>,

#[arg(
long,
help = "Time of day for the refresh in HH:MM format (e.g., 07:15)",
requires = "interval"
)]
time: Option<String>,

#[arg(
long,
help = "Day of week for the refresh (0=Sunday through 6=Saturday)",
requires = "interval"
)]
day_of_week: Option<String>,

#[arg(long, help = "Clear the refresh schedule", conflicts_with = "interval")]
clear: bool,
},

#[command(about = "Update stmo-cli to the latest version")]
Update,
}
Expand Down Expand Up @@ -174,6 +207,7 @@ enum DashboardCommands {
},
}

#[allow(clippy::too_many_lines)]
async fn run_command(client: RedashClient, command: Commands) -> Result<()> {
match command {
Commands::Discover { search, limit } => {
Expand Down Expand Up @@ -249,6 +283,26 @@ async fn run_command(client: RedashClient, command: Commands) -> Result<()> {
}
commands::archive::unarchive(&client, query_ids).await?;
}
Commands::Schedule {
query_ids,
interval,
time,
day_of_week,
clear,
} => {
if query_ids.is_empty() {
anyhow::bail!(
"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"
);
}
commands::schedule::schedule(
&query_ids,
interval,
time.as_deref(),
day_of_week.as_deref(),
clear,
)?;
}
Commands::Dashboards { command } => match command {
DashboardCommands::Discover => commands::dashboards::discover(&client).await?,
DashboardCommands::Fetch { slugs } => {
Expand Down Expand Up @@ -285,6 +339,7 @@ discover [--search TEXT] [--limit N] | fetch [IDs] [--all] | deploy [IDs] [--all
data-sources [ID] [--schema] [--refresh] | archive IDs | archive --cleanup | unarchive IDs | init | update
dashboards discover|fetch SLUGS|deploy SLUGS [--all]|archive SLUGS|unarchive SLUGS

schedule IDs --interval SECS [--time HH:MM] [--day-of-week N] | schedule IDs --clear (writes YAML; run deploy to push)
deploy: uses git diff by default; --all required outside a git repo
archive IDs: archives on server + deletes local | archive --cleanup: deletes local only for already-archived (does NOT archive on server)
dashboards: addressed by slug not ID; only favorited dashboards appear in dashboards discover
Expand Down
3 changes: 2 additions & 1 deletion templates/init/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This repository contains version-controlled Redash queries and dashboards manage
## Quick Reference

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

Expand Down Expand Up @@ -34,6 +34,7 @@ To restore: `stmo-cli unarchive <id> && stmo-cli fetch <id>`
**data-sources**: List sources, `<id> --schema` for tables
**archive**: Archive queries + delete local (`<ids>` or `--cleanup`)
**unarchive**: Restore archived queries (`<ids>`)
**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

### Dashboards
**dashboards discover**: List your favorite dashboards (IDs + names + slugs)
Expand Down
Loading