diff --git a/docs/superpowers/plans/2026-04-05-date-filters.md b/docs/superpowers/plans/2026-04-05-date-filters.md new file mode 100644 index 0000000..8764b74 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-date-filters.md @@ -0,0 +1,489 @@ +# Date Filter Flags Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `--created-after`, `--created-before`, `--updated-after`, `--updated-before` date filter flags to `jr issue list`. + +**Architecture:** Four new clap args on `IssueCommand::List` that generate JQL date clauses via `build_filter_clauses`. Date validation uses chrono in `jql.rs`. The `--before` flags add +1 day and use `<` to handle JQL's midnight semantics correctly. + +**Tech Stack:** Rust, clap (derive), chrono::NaiveDate, wiremock (tests), assert_cmd/predicates (smoke tests) + +--- + +## File Structure + +| File | Responsibility | Change type | +|------|---------------|-------------| +| `src/jql.rs` | Date validation (`validate_date`) | Add function + unit tests | +| `src/cli/mod.rs` | CLI arg definitions for `IssueCommand::List` | Add 4 args | +| `src/cli/issue/list.rs` | Early validation, JQL clause generation | Modify `handle_list` + `build_filter_clauses` | +| `tests/cli_smoke.rs` | Clap conflict smoke tests | Add 1 test | +| `tests/cli_handler.rs` | Handler-level tests with wiremock | Add 2 tests | + +--- + +### Task 1: Date Validation in `jql.rs` + +**Files:** +- Modify: `src/jql.rs` + +- [ ] **Step 1: Write failing unit tests for `validate_date`** + +Add these tests to the existing `#[cfg(test)] mod tests` block at the bottom of `src/jql.rs` (after the last existing test, before the closing `}`): + +```rust + #[test] + fn validate_date_valid_simple() { + let d = validate_date("2026-03-18").unwrap(); + assert_eq!(d.to_string(), "2026-03-18"); + } + + #[test] + fn validate_date_valid_leap_day() { + let d = validate_date("2024-02-29").unwrap(); + assert_eq!(d.to_string(), "2024-02-29"); + } + + #[test] + fn validate_date_invalid_format_slash() { + let err = validate_date("2026/03/18").unwrap_err(); + assert!(err.contains("Invalid date")); + assert!(err.contains("YYYY-MM-DD")); + } + + #[test] + fn validate_date_invalid_format_us() { + let err = validate_date("03-18-2026").unwrap_err(); + assert!(err.contains("Invalid date")); + } + + #[test] + fn validate_date_impossible_feb30() { + let err = validate_date("2026-02-30").unwrap_err(); + assert!(err.contains("Invalid date")); + } + + #[test] + fn validate_date_impossible_month13() { + let err = validate_date("2026-13-01").unwrap_err(); + assert!(err.contains("Invalid date")); + } + + #[test] + fn validate_date_empty() { + let err = validate_date("").unwrap_err(); + assert!(err.contains("Invalid date")); + } + + #[test] + fn validate_date_non_leap_feb29() { + let err = validate_date("2026-02-29").unwrap_err(); + assert!(err.contains("Invalid date")); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `export PATH="$HOME/.cargo/bin:$PATH" && cargo test --lib jql::tests::validate_date 2>&1 | tail -20` + +Expected: FAIL — `validate_date` does not exist yet. + +- [ ] **Step 3: Implement `validate_date`** + +Add this function to `src/jql.rs`, after the existing `validate_asset_key` function (before the `/// Strip ORDER BY` doc comment around line 84): + +```rust +/// Validate and parse an absolute date string in ISO 8601 format (YYYY-MM-DD). +/// +/// Returns the parsed `NaiveDate` on success. The caller needs the parsed date +/// to compute +1 day for `--before` flag JQL generation. +pub fn validate_date(s: &str) -> Result { + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| { + format!("Invalid date \"{s}\". Expected format: YYYY-MM-DD (e.g., 2026-03-18).") + }) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `export PATH="$HOME/.cargo/bin:$PATH" && cargo test --lib jql::tests::validate_date 2>&1 | tail -20` + +Expected: All 8 new tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/jql.rs +git commit -m "feat: add validate_date function for date filter flags (#113)" +``` + +--- + +### Task 2: Add CLI Flags to `mod.rs` + +**Files:** +- Modify: `src/cli/mod.rs:173-212` (the `IssueCommand::List` variant) + +- [ ] **Step 1: Add the four date filter args** + +In `src/cli/mod.rs`, inside the `List` variant of `IssueCommand`, add these four fields after the `asset` field (before the closing `}` of the `List` variant, around line 211): + +```rust + /// Show issues created on or after this date (YYYY-MM-DD) + #[arg(long, conflicts_with = "recent")] + created_after: Option, + /// Show issues created on or before this date (YYYY-MM-DD) + #[arg(long)] + created_before: Option, + /// Show issues updated on or after this date (YYYY-MM-DD) + #[arg(long)] + updated_after: Option, + /// Show issues updated on or before this date (YYYY-MM-DD) + #[arg(long)] + updated_before: Option, +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `export PATH="$HOME/.cargo/bin:$PATH" && cargo check 2>&1 | tail -20` + +Expected: Compiler error about exhaustive pattern match in `list.rs` — the `IssueCommand::List` destructuring doesn't include the new fields yet. This confirms the fields were added. + +- [ ] **Step 3: Commit** + +```bash +git add src/cli/mod.rs +git commit -m "feat: add date filter CLI flags to issue list (#113)" +``` + +--- + +### Task 3: Wire Date Flags into `list.rs` + +**Files:** +- Modify: `src/cli/issue/list.rs:57-96` (destructuring + early validation) +- Modify: `src/cli/issue/list.rs:209-218` (`build_filter_clauses` call) +- Modify: `src/cli/issue/list.rs:291-305` (unbounded query error message) +- Modify: `src/cli/issue/list.rs:491-523` (`build_filter_clauses` function) + +- [ ] **Step 1: Update the `IssueCommand::List` destructuring** + +In `src/cli/issue/list.rs`, in the `handle_list` function, update the destructuring (around line 65-81) to include the new fields. Add after `asset: asset_key,`: + +```rust + created_after, + created_before, + updated_after, + updated_before, +``` + +- [ ] **Step 2: Add early date validation** + +In `src/cli/issue/list.rs`, after the `--asset` validation block (after line 96), add validation for all four date flags: + +```rust + // Validate date filter flags early + let created_after_date = if let Some(ref d) = created_after { + Some(crate::jql::validate_date(d).map_err(JrError::UserError)?) + } else { + None + }; + let created_before_date = if let Some(ref d) = created_before { + Some(crate::jql::validate_date(d).map_err(JrError::UserError)?) + } else { + None + }; + let updated_after_date = if let Some(ref d) = updated_after { + Some(crate::jql::validate_date(d).map_err(JrError::UserError)?) + } else { + None + }; + let updated_before_date = if let Some(ref d) = updated_before { + Some(crate::jql::validate_date(d).map_err(JrError::UserError)?) + } else { + None + }; +``` + +- [ ] **Step 3: Build date JQL clauses** + +After the date validation block (Step 2), compute the JQL clause strings. The `--after` flags use `>=` directly; the `--before` flags add +1 day and use `<`: + +```rust + // Build date filter JQL clauses + let created_after_clause = created_after_date.map(|d| format!("created >= \"{}\"", d)); + let created_before_clause = created_before_date.map(|d| { + let next_day = d + chrono::Days::new(1); + format!("created < \"{}\"", next_day) + }); + let updated_after_clause = updated_after_date.map(|d| format!("updated >= \"{}\"", d)); + let updated_before_clause = updated_before_date.map(|d| { + let next_day = d + chrono::Days::new(1); + format!("updated < \"{}\"", next_day) + }); +``` + +- [ ] **Step 4: Update `build_filter_clauses` signature and call** + +Update the `build_filter_clauses` function signature in `src/cli/issue/list.rs` (around line 491) to accept the four new clauses: + +```rust +fn build_filter_clauses( + assignee_jql: Option<&str>, + reporter_jql: Option<&str>, + status: Option<&str>, + team_clause: Option<&str>, + recent: Option<&str>, + open: bool, + asset_clause: Option<&str>, + created_after_clause: Option<&str>, + created_before_clause: Option<&str>, + updated_after_clause: Option<&str>, + updated_before_clause: Option<&str>, +) -> Vec { +``` + +Add these lines at the end of the function body, before the `parts` return (after the `asset_clause` block): + +```rust + if let Some(c) = created_after_clause { + parts.push(c.to_string()); + } + if let Some(c) = created_before_clause { + parts.push(c.to_string()); + } + if let Some(c) = updated_after_clause { + parts.push(c.to_string()); + } + if let Some(c) = updated_before_clause { + parts.push(c.to_string()); + } +``` + +Update the call site (around line 210) to pass the new clauses: + +```rust + let filter_parts = build_filter_clauses( + assignee_jql.as_deref(), + reporter_jql.as_deref(), + resolved_status.as_deref(), + team_clause.as_deref(), + recent.as_deref(), + open, + asset_clause.as_deref(), + created_after_clause.as_deref(), + created_before_clause.as_deref(), + updated_after_clause.as_deref(), + updated_before_clause.as_deref(), + ); +``` + +- [ ] **Step 5: Update the unbounded query error message** + +In the guard against unbounded query (around line 298-305), update the error message to mention the new flags: + +```rust + if all_parts.is_empty() { + return Err(JrError::UserError( + "No project or filters specified. Use --project, --assignee, --reporter, --status, --open, --team, --recent, --created-after, --created-before, --updated-after, --updated-before, --asset, or --jql. \ + You can also set a default project in .jr.toml or run \"jr init\"." + .into(), + ) + .into()); + } +``` + +- [ ] **Step 6: Verify it compiles and existing tests pass** + +Run: `export PATH="$HOME/.cargo/bin:$PATH" && cargo check 2>&1 | tail -10 && cargo test --lib 2>&1 | tail -10` + +Expected: Compiles. All existing tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/cli/issue/list.rs +git commit -m "feat: wire date filter flags into JQL generation (#113)" +``` + +--- + +### Task 4: Smoke Test for `--created-after` / `--recent` Conflict + +**Files:** +- Modify: `tests/cli_smoke.rs` + +- [ ] **Step 1: Write the conflict smoke test** + +Add this test to `tests/cli_smoke.rs`, after the last existing test: + +```rust +#[test] +fn test_issue_list_created_after_and_recent_conflict() { + Command::cargo_bin("jr") + .unwrap() + .args([ + "issue", + "list", + "--created-after", + "2026-03-18", + "--recent", + "7d", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `export PATH="$HOME/.cargo/bin:$PATH" && cargo test --test cli_smoke test_issue_list_created_after_and_recent_conflict 2>&1 | tail -10` + +Expected: PASS — clap enforces the conflict declared in Task 2. + +- [ ] **Step 3: Commit** + +```bash +git add tests/cli_smoke.rs +git commit -m "test: add smoke test for --created-after/--recent conflict (#113)" +``` + +--- + +### Task 5: Handler Tests for Date Flags + +**Files:** +- Modify: `tests/cli_handler.rs` + +These tests verify that the date flags produce correct JQL when the handler runs against a wiremock server. + +- [ ] **Step 1: Write handler test for `--created-after`** + +Add this test to `tests/cli_handler.rs`, after the last existing test: + +```rust +#[tokio::test] +async fn test_handler_list_created_after() { + let server = MockServer::start().await; + + // The search endpoint should receive JQL with the date clause + Mock::given(method("POST")) + .and(path("/rest/api/3/search/jql")) + .and(body_partial_json(serde_json::json!({ + "jql": "project = \"PROJ\" AND created >= \"2026-03-18\" ORDER BY updated DESC" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json( + common::fixtures::issue_search_response(vec![common::fixtures::issue_response( + "PROJ-1", + "Test issue", + "To Do", + )]), + )) + .expect(1) + .mount(&server) + .await; + + Command::cargo_bin("jr") + .unwrap() + .env("JR_BASE_URL", server.uri()) + .env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0") + .args([ + "issue", + "list", + "--project", + "PROJ", + "--created-after", + "2026-03-18", + "--no-input", + ]) + .assert() + .success(); +} +``` + +- [ ] **Step 2: Write handler test for `--created-before` (verifies +1 day)** + +```rust +#[tokio::test] +async fn test_handler_list_created_before() { + let server = MockServer::start().await; + + // --created-before 2026-03-18 should produce created < "2026-03-19" (next day) + Mock::given(method("POST")) + .and(path("/rest/api/3/search/jql")) + .and(body_partial_json(serde_json::json!({ + "jql": "project = \"PROJ\" AND created < \"2026-03-19\" ORDER BY updated DESC" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json( + common::fixtures::issue_search_response(vec![common::fixtures::issue_response( + "PROJ-1", + "Test issue", + "To Do", + )]), + )) + .expect(1) + .mount(&server) + .await; + + Command::cargo_bin("jr") + .unwrap() + .env("JR_BASE_URL", server.uri()) + .env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0") + .args([ + "issue", + "list", + "--project", + "PROJ", + "--created-before", + "2026-03-18", + "--no-input", + ]) + .assert() + .success(); +} +``` + +- [ ] **Step 3: Run handler tests** + +Run: `export PATH="$HOME/.cargo/bin:$PATH" && cargo test --test cli_handler test_handler_list_created 2>&1 | tail -20` + +Expected: Both tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add tests/cli_handler.rs +git commit -m "test: add handler tests for date filter flags (#113)" +``` + +--- + +### Task 6: Format and Lint Check + +**Files:** (none — formatting/linting only) + +- [ ] **Step 1: Run formatter** + +Run: `export PATH="$HOME/.cargo/bin:$PATH" && cargo fmt --all` + +- [ ] **Step 2: Run clippy** + +Run: `export PATH="$HOME/.cargo/bin:$PATH" && cargo clippy -- -D warnings 2>&1 | tail -20` + +Expected: Zero warnings. + +- [ ] **Step 3: Run full test suite** + +Run: `export PATH="$HOME/.cargo/bin:$PATH" && cargo test 2>&1 | tail -20` + +Expected: All tests pass. + +- [ ] **Step 4: Commit if any formatting changes** + +```bash +git add -A +git commit -m "style: format date filter implementation (#113)" +``` + +(Skip commit if no changes.) diff --git a/docs/superpowers/specs/2026-04-05-date-filters-design.md b/docs/superpowers/specs/2026-04-05-date-filters-design.md new file mode 100644 index 0000000..57bb8f6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-date-filters-design.md @@ -0,0 +1,121 @@ +# Date Filter Flags for `jr issue list` — Design Spec + +> **Issue:** #113 — `issue list: add --created-after and --created-before date filters` + +## Problem + +`jr issue list` supports `--recent 7d` for relative date filtering but not absolute date ranges. Users who need issues created or updated within a specific date range must use raw JQL: + +```bash +jr issue list --jql 'project = PROJ AND created >= "2026-03-18"' +``` + +This requires knowing JQL syntax, which is a knowledge barrier for common queries. + +## Solution + +Add four convenience flags that generate JQL date clauses: + +| Flag | JQL generated | Meaning | +|------|--------------|---------| +| `--created-after YYYY-MM-DD` | `created >= "YYYY-MM-DD"` | Issues created on or after this date | +| `--created-before YYYY-MM-DD` | `created < "YYYY-MM-DD+1"` | Issues created on or before this date | +| `--updated-after YYYY-MM-DD` | `updated >= "YYYY-MM-DD"` | Issues updated on or after this date | +| `--updated-before YYYY-MM-DD` | `updated < "YYYY-MM-DD+1"` | Issues updated on or before this date | + +### Operator semantics — the midnight problem + +JQL interprets date-only values as midnight (00:00:00). This creates a subtle trap: + +- `created >= "2026-03-18"` means "from midnight March 18 onwards" — **includes** all of March 18 ✅ +- `created <= "2026-03-18"` means "up to midnight March 18" — **excludes** issues created during March 18 ❌ + +To give users intuitive "on or before this date" behavior, the `--before` flags add one day and use `<`: + +- `--created-before 2026-03-18` generates `created < "2026-03-19"` — includes all of March 18 ✅ + +The `--after` flags use `>=` directly since midnight-of-date is the correct lower bound. + +### Date format + +Accept `YYYY-MM-DD` only (ISO 8601 calendar date). Jira JQL also accepts `YYYY/MM/DD` and optional `HH:MM` time, but we accept only the canonical format for simplicity and consistency. Users who need time precision or alternate formats can use `--jql`. + +### Validation + +Parse dates with `chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d")` before sending to JQL. This catches: + +- Invalid format (e.g., `03-18-2026`, `2026/03/18`) +- Impossible dates (e.g., `2026-02-30`, `2026-13-01`) + +Add `validate_date(s: &str) -> Result` to `jql.rs`. Returns the parsed `NaiveDate` (needed by `--before` flags to compute +1 day). Validation happens early in `handle_list`, same pattern as `--recent`. + +### Flag conflicts + +| Flag | Conflicts with | +|------|---------------| +| `--created-after` | `--recent` (both set a lower bound on `created`) | +| `--created-before` | (none) | +| `--updated-after` | (none) | +| `--updated-before` | (none) | + +`--created-after` and `--created-before` do NOT conflict with each other — using both creates a date range. Same for the `--updated-*` pair. + +None of the date flags conflict with `--jql`. When combined, date clauses are AND'd with the user's JQL, same as all other filter flags. + +### JQL generation + +Each flag adds a clause via `build_filter_clauses` in `list.rs`. For `--after` flags, the clause is a simple string interpolation. For `--before` flags, the date is incremented by one day using `chrono::Days::new(1)` and formatted back to `YYYY-MM-DD`. + +### Composability + +All four flags combine freely with each other and with existing flags: + +```bash +# Date range +jr issue list --created-after 2026-03-01 --created-before 2026-03-31 + +# With other filters +jr issue list --created-after 2026-03-18 --assignee me --open + +# Updated date range +jr issue list --updated-after 2026-03-01 --updated-before 2026-04-01 --status "In Progress" +``` + +### Error messages + +**Invalid date format:** +``` +Invalid date "03-18-2026". Expected format: YYYY-MM-DD (e.g., 2026-03-18). +``` + +**Impossible date:** +``` +Invalid date "2026-02-30". Expected format: YYYY-MM-DD (e.g., 2026-03-18). +``` + +**Conflict with `--recent`:** +``` +error: the argument '--created-after ' cannot be used with '--recent ' +``` +(Clap's automatic conflict error message.) + +### Non-interactive / JSON output + +No interactive behavior. The flags are fully non-interactive — they take a value and generate JQL. No special JSON output handling needed; the flags only affect which issues are returned. + +## Files changed + +| File | Change | +|------|--------| +| `src/cli/mod.rs` | Add 4 new args to `IssueCommand::List` with `conflicts_with` on `created_after` | +| `src/jql.rs` | Add `validate_date(s: &str) -> Result` | +| `src/cli/issue/list.rs` | Validate dates early, pass to `build_filter_clauses`, add 4 JQL clauses | +| `tests/cli_smoke.rs` | Smoke test for `--created-after`/`--recent` conflict | +| `tests/cli_handler.rs` | Handler test for date flags generating correct JQL | + +## Out of scope + +- Time-of-day precision (`--created-after "2026-03-18 14:30"`) — use `--jql` +- `YYYY/MM/DD` format — use `--jql` +- Relative date expressions in these flags (e.g., `--created-after "2 weeks ago"`) — use `--recent` +- `startOfDay()` / `endOfDay()` JQL functions — the +1 day approach is simpler and equivalent diff --git a/src/cli/issue/list.rs b/src/cli/issue/list.rs index 363fbdb..5933c02 100644 --- a/src/cli/issue/list.rs +++ b/src/cli/issue/list.rs @@ -75,6 +75,10 @@ pub(super) async fn handle_list( points: show_points, assets: show_assets, asset: asset_key, + created_after, + created_before, + updated_after, + updated_before, } = command else { unreachable!() @@ -95,6 +99,40 @@ pub(super) async fn handle_list( crate::jql::validate_asset_key(key).map_err(JrError::UserError)?; } + // Validate date filter flags early + let created_after_date = if let Some(ref d) = created_after { + Some(crate::jql::validate_date(d).map_err(JrError::UserError)?) + } else { + None + }; + let created_before_date = if let Some(ref d) = created_before { + Some(crate::jql::validate_date(d).map_err(JrError::UserError)?) + } else { + None + }; + let updated_after_date = if let Some(ref d) = updated_after { + Some(crate::jql::validate_date(d).map_err(JrError::UserError)?) + } else { + None + }; + let updated_before_date = if let Some(ref d) = updated_before { + Some(crate::jql::validate_date(d).map_err(JrError::UserError)?) + } else { + None + }; + + // Build date filter JQL clauses + let created_after_clause = created_after_date.map(|d| format!("created >= \"{}\"", d)); + let created_before_clause = created_before_date.map(|d| { + let next_day = d + chrono::Days::new(1); + format!("created < \"{}\"", next_day) + }); + let updated_after_clause = updated_after_date.map(|d| format!("updated >= \"{}\"", d)); + let updated_before_clause = updated_before_date.map(|d| { + let next_day = d + chrono::Days::new(1); + format!("updated < \"{}\"", next_day) + }); + // Resolve --assignee and --reporter to JQL values let assignee_jql = if let Some(ref name) = assignee { Some(helpers::resolve_user(client, name, no_input).await?) @@ -207,15 +245,19 @@ pub(super) async fn handle_list( }; // Build filter clauses from all flag values - let filter_parts = build_filter_clauses( - assignee_jql.as_deref(), - reporter_jql.as_deref(), - resolved_status.as_deref(), - team_clause.as_deref(), - recent.as_deref(), + let filter_parts = build_filter_clauses(FilterOptions { + assignee_jql: assignee_jql.as_deref(), + reporter_jql: reporter_jql.as_deref(), + status: resolved_status.as_deref(), + team_clause: team_clause.as_deref(), + recent: recent.as_deref(), open, - asset_clause.as_deref(), - ); + asset_clause: asset_clause.as_deref(), + created_after_clause: created_after_clause.as_deref(), + created_before_clause: created_before_clause.as_deref(), + updated_after_clause: updated_after_clause.as_deref(), + updated_before_clause: updated_before_clause.as_deref(), + }); // Build base JQL + order by let (base_parts, order_by): (Vec, &str) = if let Some(ref raw_jql) = jql { @@ -297,7 +339,7 @@ pub(super) async fn handle_list( // Guard against unbounded query if all_parts.is_empty() { return Err(JrError::UserError( - "No project or filters specified. Use --project, --assignee, --reporter, --status, --open, --team, --recent, --asset, or --jql. \ + "No project or filters specified. Use --project, --assignee, --reporter, --status, --open, --team, --recent, --created-after, --created-before, --updated-after, --updated-before, --asset, or --jql. \ You can also set a default project in .jr.toml or run \"jr init\"." .into(), ) @@ -487,38 +529,58 @@ fn resolve_show_points(show_points: bool, sp_field_id: Option<&str>) -> Option<& } } -/// Build JQL filter clauses from resolved flag values. -fn build_filter_clauses( - assignee_jql: Option<&str>, - reporter_jql: Option<&str>, - status: Option<&str>, - team_clause: Option<&str>, - recent: Option<&str>, +/// Options bag for `build_filter_clauses` — groups all resolved JQL filter +/// fragments so the function stays within clippy's argument-count limit. +struct FilterOptions<'a> { + assignee_jql: Option<&'a str>, + reporter_jql: Option<&'a str>, + status: Option<&'a str>, + team_clause: Option<&'a str>, + recent: Option<&'a str>, open: bool, - asset_clause: Option<&str>, -) -> Vec { + asset_clause: Option<&'a str>, + created_after_clause: Option<&'a str>, + created_before_clause: Option<&'a str>, + updated_after_clause: Option<&'a str>, + updated_before_clause: Option<&'a str>, +} + +/// Build JQL filter clauses from resolved flag values. +fn build_filter_clauses(opts: FilterOptions<'_>) -> Vec { let mut parts = Vec::new(); - if let Some(a) = assignee_jql { + if let Some(a) = opts.assignee_jql { parts.push(format!("assignee = {a}")); } - if let Some(r) = reporter_jql { + if let Some(r) = opts.reporter_jql { parts.push(format!("reporter = {r}")); } - if let Some(s) = status { + if let Some(s) = opts.status { parts.push(format!("status = \"{}\"", crate::jql::escape_value(s))); } - if open { + if opts.open { parts.push("statusCategory != Done".to_string()); } - if let Some(t) = team_clause { + if let Some(t) = opts.team_clause { parts.push(t.to_string()); } - if let Some(d) = recent { + if let Some(d) = opts.recent { parts.push(format!("created >= -{d}")); } - if let Some(a) = asset_clause { + if let Some(a) = opts.asset_clause { parts.push(a.to_string()); } + if let Some(c) = opts.created_after_clause { + parts.push(c.to_string()); + } + if let Some(c) = opts.created_before_clause { + parts.push(c.to_string()); + } + if let Some(c) = opts.updated_after_clause { + parts.push(c.to_string()); + } + if let Some(c) = opts.updated_before_clause { + parts.push(c.to_string()); + } parts } @@ -880,42 +942,73 @@ mod tests { #[test] fn build_jql_parts_assignee_me() { - let parts = - build_filter_clauses(Some("currentUser()"), None, None, None, None, false, None); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: Some("currentUser()"), + reporter_jql: None, + status: None, + team_clause: None, + recent: None, + open: false, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert_eq!(parts, vec!["assignee = currentUser()"]); } #[test] fn build_jql_parts_reporter_account_id() { - let parts = build_filter_clauses( - None, - Some("5b10ac8d82e05b22cc7d4ef5"), - None, - None, - None, - false, - None, - ); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: None, + reporter_jql: Some("5b10ac8d82e05b22cc7d4ef5"), + status: None, + team_clause: None, + recent: None, + open: false, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert_eq!(parts, vec!["reporter = 5b10ac8d82e05b22cc7d4ef5"]); } #[test] fn build_jql_parts_recent() { - let parts = build_filter_clauses(None, None, None, None, Some("7d"), false, None); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: None, + reporter_jql: None, + status: None, + team_clause: None, + recent: Some("7d"), + open: false, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert_eq!(parts, vec!["created >= -7d"]); } #[test] fn build_jql_parts_all_filters() { - let parts = build_filter_clauses( - Some("currentUser()"), - Some("currentUser()"), - Some("In Progress"), - Some(r#"customfield_10001 = "uuid-123""#), - Some("30d"), - false, - None, - ); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: Some("currentUser()"), + reporter_jql: Some("currentUser()"), + status: Some("In Progress"), + team_clause: Some(r#"customfield_10001 = "uuid-123""#), + recent: Some("30d"), + open: false, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert_eq!(parts.len(), 5); assert!(parts.contains(&"assignee = currentUser()".to_string())); assert!(parts.contains(&"reporter = currentUser()".to_string())); @@ -926,13 +1019,37 @@ mod tests { #[test] fn build_jql_parts_empty() { - let parts = build_filter_clauses(None, None, None, None, None, false, None); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: None, + reporter_jql: None, + status: None, + team_clause: None, + recent: None, + open: false, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert!(parts.is_empty()); } #[test] fn build_jql_parts_jql_plus_status_compose() { - let filter = build_filter_clauses(None, None, Some("Done"), None, None, false, None); + let filter = build_filter_clauses(FilterOptions { + assignee_jql: None, + reporter_jql: None, + status: Some("Done"), + team_clause: None, + recent: None, + open: false, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); let mut all_parts = vec!["type = Bug".to_string()]; all_parts.extend(filter); let jql = all_parts.join(" AND "); @@ -941,27 +1058,55 @@ mod tests { #[test] fn build_jql_parts_status_escaping() { - let parts = build_filter_clauses( - None, - None, - Some(r#"He said "hi" \o/"#), - None, - None, - false, - None, - ); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: None, + reporter_jql: None, + status: Some(r#"He said "hi" \o/"#), + team_clause: None, + recent: None, + open: false, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert_eq!(parts, vec![r#"status = "He said \"hi\" \\o/""#.to_string()]); } #[test] fn build_jql_parts_open() { - let parts = build_filter_clauses(None, None, None, None, None, true, None); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: None, + reporter_jql: None, + status: None, + team_clause: None, + recent: None, + open: true, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert_eq!(parts, vec!["statusCategory != Done"]); } #[test] fn build_jql_parts_open_with_assignee() { - let parts = build_filter_clauses(Some("currentUser()"), None, None, None, None, true, None); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: Some("currentUser()"), + reporter_jql: None, + status: None, + team_clause: None, + recent: None, + open: true, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert_eq!(parts.len(), 2); assert!(parts.contains(&"assignee = currentUser()".to_string())); assert!(parts.contains(&"statusCategory != Done".to_string())); @@ -969,15 +1114,19 @@ mod tests { #[test] fn build_jql_parts_all_filters_with_open() { - let parts = build_filter_clauses( - Some("currentUser()"), - Some("currentUser()"), - None, // status conflicts with open, so None here - Some(r#"customfield_10001 = "uuid-123""#), - Some("30d"), - true, - None, - ); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: Some("currentUser()"), + reporter_jql: Some("currentUser()"), + status: None, // status conflicts with open, so None here + team_clause: Some(r#"customfield_10001 = "uuid-123""#), + recent: Some("30d"), + open: true, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert_eq!(parts.len(), 5); assert!(parts.contains(&"assignee = currentUser()".to_string())); assert!(parts.contains(&"reporter = currentUser()".to_string())); @@ -989,27 +1138,101 @@ mod tests { #[test] fn build_jql_parts_asset_clause() { let clause = r#""Client" IN aqlFunction("Key = \"CUST-5\"")"#; - let parts = build_filter_clauses(None, None, None, None, None, false, Some(clause)); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: None, + reporter_jql: None, + status: None, + team_clause: None, + recent: None, + open: false, + asset_clause: Some(clause), + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert_eq!(parts, vec![clause.to_string()]); } #[test] fn build_jql_parts_asset_with_assignee() { let clause = r#""Client" IN aqlFunction("Key = \"CUST-5\"")"#; - let parts = build_filter_clauses( - Some("currentUser()"), - None, - None, - None, - None, - false, - Some(clause), - ); + let parts = build_filter_clauses(FilterOptions { + assignee_jql: Some("currentUser()"), + reporter_jql: None, + status: None, + team_clause: None, + recent: None, + open: false, + asset_clause: Some(clause), + created_after_clause: None, + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); assert_eq!(parts.len(), 2); assert!(parts.contains(&"assignee = currentUser()".to_string())); assert!(parts.contains(&clause.to_string())); } + #[test] + fn build_jql_parts_created_after_clause() { + let parts = build_filter_clauses(FilterOptions { + assignee_jql: None, + reporter_jql: None, + status: None, + team_clause: None, + recent: None, + open: false, + asset_clause: None, + created_after_clause: Some("created >= \"2026-03-18\""), + created_before_clause: None, + updated_after_clause: None, + updated_before_clause: None, + }); + assert_eq!(parts, vec!["created >= \"2026-03-18\""]); + } + + #[test] + fn build_jql_parts_updated_after_and_before_clauses() { + let parts = build_filter_clauses(FilterOptions { + assignee_jql: None, + reporter_jql: None, + status: None, + team_clause: None, + recent: None, + open: false, + asset_clause: None, + created_after_clause: None, + created_before_clause: None, + updated_after_clause: Some("updated >= \"2026-03-01\""), + updated_before_clause: Some("updated < \"2026-04-01\""), + }); + assert_eq!(parts.len(), 2); + assert!(parts.contains(&"updated >= \"2026-03-01\"".to_string())); + assert!(parts.contains(&"updated < \"2026-04-01\"".to_string())); + } + + #[test] + fn build_jql_parts_created_date_range() { + let parts = build_filter_clauses(FilterOptions { + assignee_jql: None, + reporter_jql: None, + status: None, + team_clause: None, + recent: None, + open: false, + asset_clause: None, + created_after_clause: Some("created >= \"2026-03-01\""), + created_before_clause: Some("created < \"2026-04-01\""), + updated_after_clause: None, + updated_before_clause: None, + }); + assert_eq!(parts.len(), 2); + assert!(parts.contains(&"created >= \"2026-03-01\"".to_string())); + assert!(parts.contains(&"created < \"2026-04-01\"".to_string())); + } + #[test] fn build_jql_base_parts_jql_with_project() { let (parts, order_by) = build_jql_base_parts("priority = Highest", Some("PROJ")); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1e40002..e14b2c2 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -69,7 +69,7 @@ pub enum Command { /// Manage issues Issue { #[command(subcommand)] - command: IssueCommand, + command: Box, }, /// Manage boards Board { @@ -209,6 +209,18 @@ pub enum IssueCommand { /// Filter by linked asset object key (e.g., CUST-5) #[arg(long)] asset: Option, + /// Show issues created on or after this date (YYYY-MM-DD) + #[arg(long, conflicts_with = "recent")] + created_after: Option, + /// Show issues created on or before this date (YYYY-MM-DD) + #[arg(long)] + created_before: Option, + /// Show issues updated on or after this date (YYYY-MM-DD) + #[arg(long)] + updated_after: Option, + /// Show issues updated on or before this date (YYYY-MM-DD) + #[arg(long)] + updated_before: Option, }, /// Create a new issue Create { diff --git a/src/jql.rs b/src/jql.rs index 5c198c3..dfc1a53 100644 --- a/src/jql.rs +++ b/src/jql.rs @@ -81,6 +81,16 @@ pub fn build_asset_clause(asset_key: &str, cmdb_fields: &[(String, String)]) -> } } +/// Validate and parse an absolute date string in ISO 8601 format (YYYY-MM-DD). +/// +/// Returns the parsed `NaiveDate` on success. The caller needs the parsed date +/// to compute +1 day for `--before` flag JQL generation. +pub fn validate_date(s: &str) -> Result { + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| { + format!("Invalid date \"{s}\". Expected format: YYYY-MM-DD (e.g., 2026-03-18).") + }) +} + /// Strip `ORDER BY` clause from JQL for use with count-only endpoints. /// /// The approximate-count endpoint only needs the WHERE clause. ORDER BY is @@ -296,6 +306,55 @@ mod tests { r#""My \"Assets\"" IN aqlFunction("Key = \"OBJ-1\"")"# ); } + + #[test] + fn validate_date_valid_simple() { + let d = validate_date("2026-03-18").unwrap(); + assert_eq!(d.to_string(), "2026-03-18"); + } + + #[test] + fn validate_date_valid_leap_day() { + let d = validate_date("2024-02-29").unwrap(); + assert_eq!(d.to_string(), "2024-02-29"); + } + + #[test] + fn validate_date_invalid_format_slash() { + let err = validate_date("2026/03/18").unwrap_err(); + assert!(err.contains("Invalid date")); + assert!(err.contains("YYYY-MM-DD")); + } + + #[test] + fn validate_date_invalid_format_us() { + let err = validate_date("03-18-2026").unwrap_err(); + assert!(err.contains("Invalid date")); + } + + #[test] + fn validate_date_impossible_feb30() { + let err = validate_date("2026-02-30").unwrap_err(); + assert!(err.contains("Invalid date")); + } + + #[test] + fn validate_date_impossible_month13() { + let err = validate_date("2026-13-01").unwrap_err(); + assert!(err.contains("Invalid date")); + } + + #[test] + fn validate_date_empty() { + let err = validate_date("").unwrap_err(); + assert!(err.contains("Invalid date")); + } + + #[test] + fn validate_date_non_leap_feb29() { + let err = validate_date("2026-02-29").unwrap_err(); + assert!(err.contains("Invalid date")); + } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index a5d7c83..25cb3a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -114,7 +114,7 @@ async fn run(cli: Cli) -> anyhow::Result<()> { let config = config::Config::load()?; let client = api::client::JiraClient::from_config(&config, cli.verbose)?; cli::issue::handle( - command, + *command, &cli.output, &config, &client, diff --git a/tests/cli_handler.rs b/tests/cli_handler.rs index 8bdfa46..766a391 100644 --- a/tests/cli_handler.rs +++ b/tests/cli_handler.rs @@ -504,3 +504,101 @@ async fn test_handler_unassign_idempotent() { .stdout(predicate::str::contains("\"key\": \"HDL-8\"")) .stdout(predicate::str::contains("\"assignee\": null")); } + +#[tokio::test] +async fn test_handler_list_created_after() { + let server = MockServer::start().await; + + // Project existence check + Mock::given(method("GET")) + .and(path("/rest/api/3/project/PROJ")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "key": "PROJ", + "id": "10000", + "name": "Test Project" + }))) + .mount(&server) + .await; + + // The search endpoint should receive JQL with the date clause + Mock::given(method("POST")) + .and(path("/rest/api/3/search/jql")) + .and(body_partial_json(serde_json::json!({ + "jql": "project = \"PROJ\" AND created >= \"2026-03-18\" ORDER BY updated DESC" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json( + common::fixtures::issue_search_response(vec![common::fixtures::issue_response( + "PROJ-1", + "Test issue", + "To Do", + )]), + )) + .expect(1) + .mount(&server) + .await; + + Command::cargo_bin("jr") + .unwrap() + .env("JR_BASE_URL", server.uri()) + .env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0") + .args([ + "issue", + "list", + "--project", + "PROJ", + "--created-after", + "2026-03-18", + "--no-input", + ]) + .assert() + .success(); +} + +#[tokio::test] +async fn test_handler_list_created_before() { + let server = MockServer::start().await; + + // Project existence check + Mock::given(method("GET")) + .and(path("/rest/api/3/project/PROJ")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "key": "PROJ", + "id": "10000", + "name": "Test Project" + }))) + .mount(&server) + .await; + + // --created-before 2026-03-18 should produce created < "2026-03-19" (next day) + Mock::given(method("POST")) + .and(path("/rest/api/3/search/jql")) + .and(body_partial_json(serde_json::json!({ + "jql": "project = \"PROJ\" AND created < \"2026-03-19\" ORDER BY updated DESC" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json( + common::fixtures::issue_search_response(vec![common::fixtures::issue_response( + "PROJ-1", + "Test issue", + "To Do", + )]), + )) + .expect(1) + .mount(&server) + .await; + + Command::cargo_bin("jr") + .unwrap() + .env("JR_BASE_URL", server.uri()) + .env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0") + .args([ + "issue", + "list", + "--project", + "PROJ", + "--created-before", + "2026-03-18", + "--no-input", + ]) + .assert() + .success(); +} diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index c00c0f0..8d94b18 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -315,3 +315,20 @@ fn test_sprint_current_all_and_limit_conflict() { .failure() .stderr(predicate::str::contains("cannot be used with")); } + +#[test] +fn test_issue_list_created_after_and_recent_conflict() { + Command::cargo_bin("jr") + .unwrap() + .args([ + "issue", + "list", + "--created-after", + "2026-03-18", + "--recent", + "7d", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); +}