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 docs/HELP.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ List bugs for a given repository
* `--vulns` — Only show security vulnerabilities
* `--introduced-by <INTRODUCED_BY>` — Only show bugs introduced by these authors (comma-separated or repeat flag)
* `--scan-id <SCAN_ID>` — Filter bugs to a specific scan by workflow request ID
* `--since <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 <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 <LIMIT>` — Maximum number of results per page

Expand Down
116 changes: 114 additions & 2 deletions src/commands/bugs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +24,31 @@ fn filter_vulns_only(bugs: &[Bug]) -> Vec<Bug> {
.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<chrono::Utc>,
) -> Result<Option<i64>> {
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<i64>, until_ms: Option<i64>) -> Vec<Bug> {
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<Bug> {
bugs.iter()
Expand Down Expand Up @@ -109,6 +134,16 @@ pub enum BugCommands {
#[arg(long)]
scan_id: Option<String>,

/// 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<String>,

/// Only show bugs created at or before this point. Same forms as --since.
#[arg(long)]
until: Option<String>,

/// Auto-paginate: fetch every matching bug instead of a single page.
#[arg(long, conflicts_with_all = ["page", "limit"])]
all: bool,
Expand Down Expand Up @@ -345,6 +380,8 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> {
vulns,
introduced_by,
scan_id,
since,
until,
all,
limit,
page,
Expand All @@ -362,10 +399,26 @@ 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 = 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
|| !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);
}
Expand Down Expand Up @@ -734,6 +787,65 @@ mod tests {
assert!(filtered.is_empty());
}

// ── filter_by_time_range ─────────────────────────────────────────

fn time_ranged_bugs() -> Vec<Bug> {
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]
Expand Down
24 changes: 24 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
168 changes: 165 additions & 3 deletions src/utils/datetime.rs
Original file line number Diff line number Diff line change
@@ -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(),
)
Expand All @@ -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<Utc>) -> Result<DateTime<Utc>> {
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 `<n><unit>` 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<Duration> {
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)
Expand Down Expand Up @@ -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<Utc> {
// 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);
}
}
Loading