From a6951fd46355e11fd9c3e273d04ff493db2d7fd2 Mon Sep 17 00:00:00 2001 From: Sachin Iyer Date: Tue, 5 May 2026 11:18:20 -0700 Subject: [PATCH 1/3] feat(bugs): add --since/--until time-window filters to bugs list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triage workflows kept piping `--format json` into Python that filtered on `createdAt > now - 1d` to do what `--since 1d` should do natively. Add `--since` and `--until` flags accepting: - durations: 30s, 15m, 24h, 7d, 2w (resolved as `now - duration`) - ISO dates: 2024-01-15 (midnight UTC) - RFC3339 timestamps: 2024-01-15T12:00:00Z The bugs API has no server-side date filter, so we route through the existing `fetch_all_bugs` paginator and filter client-side on `createdAt` — the same shape as `--vulns` / `--introduced-by`. Both flags resolve against a single `now` so a window like `--since 7d --until 1d` reads as one interval. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/bugs.rs | 109 ++++++++++++++++++++++++++- src/lib.rs | 24 ++++++ src/utils/datetime.rs | 168 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 296 insertions(+), 5 deletions(-) diff --git a/src/commands/bugs.rs b/src/commands/bugs.rs index ee8a63a..2a17d1b 100644 --- a/src/commands/bugs.rs +++ b/src/commands/bugs.rs @@ -11,7 +11,7 @@ use crate::api::types::{ BugDismissalReason, BugId, BugReviewState, ListPublicBugsWorkflowRequestId, RepoId, }; use crate::output::{output_list, SectionRenderer}; -use crate::utils::datetime::format_datetime; +use crate::utils::datetime::{format_datetime, parse_time_spec}; use crate::utils::git::resolve_repo_arg; use crate::utils::pagination::page_to_offset; use crate::utils::repos::resolve_repo_id; @@ -24,6 +24,16 @@ fn filter_vulns_only(bugs: &[Bug]) -> Vec { .collect() } +/// Return only bugs whose `createdAt` falls within the given inclusive bounds. +fn filter_by_time_range(bugs: &[Bug], since_ms: Option, until_ms: Option) -> Vec { + bugs.iter() + .filter(|b| { + since_ms.is_none_or(|s| b.created_at >= s) && until_ms.is_none_or(|u| b.created_at <= u) + }) + .cloned() + .collect() +} + /// Return only bugs whose `introducedIn.author` case-insensitively matches one of `authors`. fn filter_by_introduced_by(bugs: &[Bug], authors: &[String]) -> Vec { bugs.iter() @@ -109,6 +119,16 @@ pub enum BugCommands { #[arg(long)] scan_id: Option, + /// Only show bugs created at or after this point. + /// Accepts a duration (e.g. 1d, 24h, 30m) interpreted as "now minus + /// this", an ISO date (YYYY-MM-DD), or an RFC3339 timestamp. + #[arg(long)] + since: Option, + + /// Only show bugs created at or before this point. Same forms as --since. + #[arg(long)] + until: Option, + /// Auto-paginate: fetch every matching bug instead of a single page. #[arg(long, conflicts_with_all = ["page", "limit"])] all: bool, @@ -345,6 +365,8 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { vulns, introduced_by, scan_id, + since, + until, all, limit, page, @@ -362,10 +384,34 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { .transpose() .context("Invalid scan ID format (expected wr_...)")?; - if *all || *vulns || !introduced_by.is_empty() { + // Resolve --since/--until against the same `now` so a relative + // window like `--since 7d --until 1d` reads as a single half-open + // interval anchored to the same instant. + let now = chrono::Utc::now(); + let since_ms: Option = since + .as_deref() + .map(|s| parse_time_spec(s, now).map(|dt| dt.timestamp_millis())) + .transpose() + .context("Invalid --since value")?; + let until_ms: Option = until + .as_deref() + .map(|s| parse_time_spec(s, now).map(|dt| dt.timestamp_millis())) + .transpose() + .context("Invalid --until value")?; + + let needs_full_fetch = *all + || *vulns + || !introduced_by.is_empty() + || since_ms.is_some() + || until_ms.is_some(); + + if needs_full_fetch { let all_bugs = fetch_all_bugs(&client, &resolved_repo_id, *status, scan_id.as_ref()).await?; let mut filtered = all_bugs; + if since_ms.is_some() || until_ms.is_some() { + filtered = filter_by_time_range(&filtered, since_ms, until_ms); + } if *vulns { filtered = filter_vulns_only(&filtered); } @@ -734,6 +780,65 @@ mod tests { assert!(filtered.is_empty()); } + // ── filter_by_time_range ───────────────────────────────────────── + + fn time_ranged_bugs() -> Vec { + vec![ + serde_json::from_value(serde_json::json!({ + "id": "bug_old", "title": "old", "summary": "...", + "createdAt": 1_000, "repoId": "repo_1", "linkedIssues": [] + })) + .unwrap(), + serde_json::from_value(serde_json::json!({ + "id": "bug_mid", "title": "mid", "summary": "...", + "createdAt": 2_000, "repoId": "repo_1", "linkedIssues": [] + })) + .unwrap(), + serde_json::from_value(serde_json::json!({ + "id": "bug_new", "title": "new", "summary": "...", + "createdAt": 3_000, "repoId": "repo_1", "linkedIssues": [] + })) + .unwrap(), + ] + } + + #[test] + fn time_range_no_bounds_returns_all() { + let bugs = time_ranged_bugs(); + assert_eq!(filter_by_time_range(&bugs, None, None).len(), 3); + } + + #[test] + fn time_range_since_is_inclusive_lower_bound() { + let bugs = time_ranged_bugs(); + let filtered = filter_by_time_range(&bugs, Some(2_000), None); + let ids: Vec<_> = filtered.iter().map(|b| b.id.to_string()).collect(); + assert_eq!(ids, vec!["bug_mid", "bug_new"]); + } + + #[test] + fn time_range_until_is_inclusive_upper_bound() { + let bugs = time_ranged_bugs(); + let filtered = filter_by_time_range(&bugs, None, Some(2_000)); + let ids: Vec<_> = filtered.iter().map(|b| b.id.to_string()).collect(); + assert_eq!(ids, vec!["bug_old", "bug_mid"]); + } + + #[test] + fn time_range_both_bounds_clamps_to_window() { + let bugs = time_ranged_bugs(); + let filtered = filter_by_time_range(&bugs, Some(2_000), Some(2_000)); + let ids: Vec<_> = filtered.iter().map(|b| b.id.to_string()).collect(); + assert_eq!(ids, vec!["bug_mid"]); + } + + #[test] + fn time_range_inverted_window_is_empty() { + // since > until: nothing matches; we don't error, we just return empty. + let bugs = time_ranged_bugs(); + assert!(filter_by_time_range(&bugs, Some(3_000), Some(1_000)).is_empty()); + } + // ── collect_authors ────────────────────────────────────────────── #[test] diff --git a/src/lib.rs b/src/lib.rs index 1a671ca..61c8be2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -468,6 +468,30 @@ mod tests { } } + #[test] + fn bugs_list_since_until_parses() { + let cli = Cli::try_parse_from([ + "detail", + "bugs", + "list", + "owner/repo", + "--since", + "1d", + "--until", + "2024-01-15", + ]) + .unwrap(); + if let Commands::Bugs { + command: commands::bugs::BugCommands::List { since, until, .. }, + } = &cli.command + { + assert_eq!(since.as_deref(), Some("1d")); + assert_eq!(until.as_deref(), Some("2024-01-15")); + } else { + panic!("expected bugs list command"); + } + } + #[test] fn rejects_repos_list_page_zero() { let cli = Cli::try_parse_from(["detail", "repos", "list", "--page", "0"]); diff --git a/src/utils/datetime.rs b/src/utils/datetime.rs index 67d4c4c..49b56dc 100644 --- a/src/utils/datetime.rs +++ b/src/utils/datetime.rs @@ -1,11 +1,12 @@ -use chrono::Local; +use anyhow::{anyhow, Result}; +use chrono::{DateTime, Duration, Local, NaiveDate, Utc}; /// Format a UTC timestamp (in milliseconds) in the machine's local timezone. fn format_timestamp(timestamp_ms: i64, fmt: &str) -> String { // `from_timestamp_millis` floors toward negative infinity, so timestamps // in (-1000, 0) correctly land in the second before the epoch instead of // collapsing to epoch via integer-division truncation. - chrono::DateTime::from_timestamp_millis(timestamp_ms).map_or_else( + DateTime::from_timestamp_millis(timestamp_ms).map_or_else( || "-".into(), |dt| dt.with_timezone(&Local).format(fmt).to_string(), ) @@ -21,12 +22,62 @@ pub fn format_datetime(timestamp_ms: i64) -> String { format_timestamp(timestamp_ms, "%Y-%m-%d %H:%M:%S %Z") } +/// Parse a `--since` / `--until` value into a UTC instant relative to `now`. +/// +/// Accepted forms: +/// * Relative duration suffixed with `s|m|h|d|w` — e.g. `30s`, `15m`, +/// `24h`, `7d`, `2w`. Resolves to `now - duration`. +/// * RFC3339 timestamp — e.g. `2024-01-15T12:00:00Z`. +/// * `YYYY-MM-DD` — interpreted as midnight UTC on that date. +pub fn parse_time_spec(s: &str, now: DateTime) -> Result> { + let trimmed = s.trim(); + if let Some(d) = parse_relative_duration(trimmed) { + return now + .checked_sub_signed(d) + .ok_or_else(|| anyhow!("'{trimmed}' resolves to an out-of-range timestamp")); + } + if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) { + return Ok(dt.with_timezone(&Utc)); + } + if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") { + if let Some(naive) = date.and_hms_opt(0, 0, 0) { + return Ok(naive.and_utc()); + } + } + Err(anyhow!( + "could not parse '{trimmed}' as a duration (e.g. 1d, 24h), \ + a date (YYYY-MM-DD), or an RFC3339 timestamp" + )) +} + +/// Parse `` where unit is one of `s|m|h|d|w` (case-insensitive). +/// Returns `None` when the input doesn't match — callers fall back to +/// absolute-date parsing. +fn parse_relative_duration(s: &str) -> Option { + let unit = s.chars().last()?; + if !unit.is_ascii_alphabetic() { + return None; + } + let n: i64 = s.strip_suffix(unit)?.trim().parse().ok()?; + if n < 0 { + return None; + } + match unit.to_ascii_lowercase() { + 's' => Duration::try_seconds(n), + 'm' => Duration::try_minutes(n), + 'h' => Duration::try_hours(n), + 'd' => Duration::try_days(n), + 'w' => Duration::try_weeks(n), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; fn expected_local(timestamp_ms: i64, fmt: &str) -> String { - chrono::DateTime::from_timestamp_millis(timestamp_ms) + DateTime::from_timestamp_millis(timestamp_ms) .expect("valid timestamp") .with_timezone(&Local) .format(fmt) @@ -83,4 +134,115 @@ mod tests { // zero previously collapsed this whole window onto 1970-01-01 00:00:00. assert_ne!(format_datetime(-1), format_datetime(0)); } + + // ── parse_time_spec ────────────────────────────────────────────── + + fn fixed_now() -> DateTime { + // 2025-06-01 00:00:00 UTC + DateTime::from_timestamp(1_748_736_000, 0).expect("valid timestamp") + } + + #[test] + fn parse_time_spec_seconds() { + let now = fixed_now(); + let parsed = parse_time_spec("30s", now).expect("parses"); + assert_eq!((now - parsed).num_seconds(), 30); + } + + #[test] + fn parse_time_spec_minutes() { + let now = fixed_now(); + let parsed = parse_time_spec("15m", now).expect("parses"); + assert_eq!((now - parsed).num_minutes(), 15); + } + + #[test] + fn parse_time_spec_hours() { + let now = fixed_now(); + let parsed = parse_time_spec("24h", now).expect("parses"); + assert_eq!((now - parsed).num_hours(), 24); + } + + #[test] + fn parse_time_spec_days() { + let now = fixed_now(); + let parsed = parse_time_spec("7d", now).expect("parses"); + assert_eq!((now - parsed).num_days(), 7); + } + + #[test] + fn parse_time_spec_weeks() { + let now = fixed_now(); + let parsed = parse_time_spec("2w", now).expect("parses"); + assert_eq!((now - parsed).num_days(), 14); + } + + #[test] + fn parse_time_spec_unit_case_insensitive() { + let now = fixed_now(); + let lower = parse_time_spec("3d", now).expect("parses"); + let upper = parse_time_spec("3D", now).expect("parses"); + assert_eq!(lower, upper); + } + + #[test] + fn parse_time_spec_zero_duration_is_now() { + let now = fixed_now(); + let parsed = parse_time_spec("0d", now).expect("parses"); + assert_eq!(parsed, now); + } + + #[test] + fn parse_time_spec_iso_date() { + let now = fixed_now(); + let parsed = parse_time_spec("2024-01-15", now).expect("parses"); + // Independent of `now`: midnight UTC on the literal date. + assert_eq!(parsed.timestamp(), 1_705_276_800); + } + + #[test] + fn parse_time_spec_rfc3339() { + let now = fixed_now(); + let parsed = parse_time_spec("2024-01-15T12:00:00Z", now).expect("parses"); + assert_eq!(parsed.timestamp(), 1_705_320_000); + } + + #[test] + fn parse_time_spec_rfc3339_with_offset_normalizes_to_utc() { + let now = fixed_now(); + let with_offset = parse_time_spec("2024-01-15T12:00:00-05:00", now).expect("parses"); + let utc = parse_time_spec("2024-01-15T17:00:00Z", now).expect("parses"); + assert_eq!(with_offset, utc); + } + + #[test] + fn parse_time_spec_negative_duration_rejected() { + let now = fixed_now(); + assert!(parse_time_spec("-3d", now).is_err()); + } + + #[test] + fn parse_time_spec_unknown_unit_rejected() { + let now = fixed_now(); + assert!(parse_time_spec("3x", now).is_err()); + } + + #[test] + fn parse_time_spec_empty_rejected() { + let now = fixed_now(); + assert!(parse_time_spec("", now).is_err()); + } + + #[test] + fn parse_time_spec_unit_only_rejected() { + let now = fixed_now(); + assert!(parse_time_spec("d", now).is_err()); + } + + #[test] + fn parse_time_spec_trims_whitespace() { + let now = fixed_now(); + let parsed = parse_time_spec(" 1d ", now).expect("parses"); + assert_eq!((now - parsed).num_days(), 1); + } } From 9be9419d2baa529bbbc5f6df193bc4410ecf255b Mon Sep 17 00:00:00 2001 From: Sachin Iyer Date: Wed, 6 May 2026 13:34:15 -0700 Subject: [PATCH 2/3] docs(help): regenerate HELP.md for the new --since/--until flags Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/HELP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/HELP.md b/docs/HELP.md index bb24c45..26b91d8 100644 --- a/docs/HELP.md +++ b/docs/HELP.md @@ -139,6 +139,8 @@ List bugs for a given repository * `--vulns` — Only show security vulnerabilities * `--introduced-by ` — Only show bugs introduced by these authors (comma-separated or repeat flag) * `--scan-id ` — Filter bugs to a specific scan by workflow request ID +* `--since ` — Only show bugs created at or after this point. Accepts a duration (e.g. 1d, 24h, 30m) interpreted as "now minus this", an ISO date (YYYY-MM-DD), or an RFC3339 timestamp +* `--until ` — Only show bugs created at or before this point. Same forms as --since * `--all` — Auto-paginate: fetch every matching bug instead of a single page * `--limit ` — Maximum number of results per page From a808e7529d8a866fecd55eafdf2201fc16797a1c Mon Sep 17 00:00:00 2001 From: Sachin Iyer Date: Wed, 6 May 2026 13:46:04 -0700 Subject: [PATCH 3/3] fix(bugs): surface parse hint when --since/--until value is invalid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual smoke-test against the live API showed `--since garbage` produced just `Error: Invalid --since value` — anyhow's default Display hides chained context, so the parse-error hint listing accepted forms (durations, ISO date, RFC3339) was lost. Flatten the chain: emit one self-contained message that names the flag and includes the parser's diagnostic, e.g.: Error: invalid --since value: could not parse 'garbage' as a duration (e.g. 1d, 24h), a date (YYYY-MM-DD), or an RFC3339 timestamp Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/bugs.rs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/commands/bugs.rs b/src/commands/bugs.rs index 2a17d1b..9ee29a8 100644 --- a/src/commands/bugs.rs +++ b/src/commands/bugs.rs @@ -24,6 +24,21 @@ fn filter_vulns_only(bugs: &[Bug]) -> Vec { .collect() } +/// Resolve a `--since` / `--until` flag value to epoch millis, flattening +/// `parse_time_spec`'s error into the top-level message so users see the +/// accepted-form list without needing `RUST_LOG`-style chain expansion. +fn resolve_time_flag( + name: &str, + value: Option<&str>, + now: chrono::DateTime, +) -> Result> { + value.map_or(Ok(None), |s| { + parse_time_spec(s, now) + .map(|dt| Some(dt.timestamp_millis())) + .map_err(|e| anyhow::anyhow!("invalid {name} value: {e}")) + }) +} + /// Return only bugs whose `createdAt` falls within the given inclusive bounds. fn filter_by_time_range(bugs: &[Bug], since_ms: Option, until_ms: Option) -> Vec { bugs.iter() @@ -388,16 +403,8 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { // window like `--since 7d --until 1d` reads as a single half-open // interval anchored to the same instant. let now = chrono::Utc::now(); - let since_ms: Option = since - .as_deref() - .map(|s| parse_time_spec(s, now).map(|dt| dt.timestamp_millis())) - .transpose() - .context("Invalid --since value")?; - let until_ms: Option = until - .as_deref() - .map(|s| parse_time_spec(s, now).map(|dt| dt.timestamp_millis())) - .transpose() - .context("Invalid --until value")?; + let since_ms = resolve_time_flag("--since", since.as_deref(), now)?; + let until_ms = resolve_time_flag("--until", until.as_deref(), now)?; let needs_full_fetch = *all || *vulns