From 4ce99507065957f5ab4c3f88356a5bc82a40bec6 Mon Sep 17 00:00:00 2001 From: Arisu Tachibana Date: Tue, 20 Oct 2026 20:59:44 +0900 Subject: [PATCH] results: support exact issue date ranges Signed-off-by: Arisu Tachibana --- README.md | 12 ++ docs/issues.md | 33 ++++- kcidev/api.py | 9 +- kcidev/libs/dashboard.py | 44 +++++- kcidev/subcommands/results/__init__.py | 42 +++++- tests/test_issue_date_range.py | 187 +++++++++++++++++++++++++ 6 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 tests/test_issue_date_range.py diff --git a/README.md b/README.md index ad57eb99..9439a11e 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,20 @@ builds = client.get_builds( commit="0123456789abcdef0123456789abcdef01234567", arch="x86_64", ) + +issues = client.get_issue_list( + origin="maestro", + start_date="2026-08-01T00:00:00Z", + end_date="2026-08-08T00:00:00Z", +) ``` +`get_issue_list()` defaults to the last 7 days. Pass `days=N` for a relative +window, or both `start_date` and `end_date` for exact boundaries, without +`days`. Date-only values use midnight UTC; timestamps must include a timezone. +The CLI equivalent is `kci-dev results issues --start-date ... --end-date ...`, +with optional `--origin` and `--json` (see [issue commands](docs/issues.md)). + ### Run kci-dev subcommands from Python For existing integrations that still need full CLI behavior, the public API can diff --git a/docs/issues.md b/docs/issues.md index c8fe61a3..4d0a9831 100644 --- a/docs/issues.md +++ b/docs/issues.md @@ -14,7 +14,8 @@ Fetch KernelCI Database (KCIDB) issues associated with **builds** and **boots**, ```bash kci-dev results issues [--builds | --boots | --new] - [--days ] [--arch ] [--origin ] [--latest] + [--days | --start-date --end-date ] + [--arch ] [--origin ] [--latest] [--giturl --branch --commit ] [--git-folder ] ``` @@ -33,6 +34,28 @@ kci-dev results issues [--builds | --boots | --new] ## Common Workflows +### List issues within an exact date range + +```bash +kci-dev results issues --origin maestro \ + --start-date 2026-08-01T00:00:00Z \ + --end-date 2026-08-08T00:00:00Z + +# Date-only boundaries mean midnight UTC, including the end boundary. +kci-dev results issues --origin maestro \ + --start-date 2026-08-01 --end-date 2026-08-08 --json +``` + +Both boundaries are required. Timestamps must include `Z` or a UTC offset, such +as `2026-08-01T09:00:00+09:00`. Boundaries are sent as Unix seconds without +rounding to whole days; fractional seconds are retained. The start must not be +later than the end. A date-only end is not expanded to the end of that day. + +Explicit ranges apply to the issue listing, with optional `--origin` and +`--json`. They cannot be combined with `--days`, `--new`, `--missing`, checkout +selection, or the build/boot, architecture and tree options. Unsupported +combinations produce a usage error rather than silently ignoring the range. + ### Detect build issues ```bash @@ -90,8 +113,12 @@ kci-dev results issues --builds --arch arm64 --days 3 --latest ### Dashboard Scope -* `--days ` *(int, default: 7)* - Time window (in days) to consider when scanning dashboard results. +* `--days ` *(int, default: 5)* + Time window (in days) to consider when scanning dashboard results. For the + issue listing, this is measured backwards from the current time in UTC. +* `--start-date ` and `--end-date ` + Exact issue-listing boundaries. Accept `YYYY-MM-DD` (midnight UTC) or + timezone-aware ISO 8601 timestamps. Use both together, without `--days`. * `--origin ` *(default: `maestro`)* KCIDB origin to query. * `--arch ` diff --git a/kcidev/api.py b/kcidev/api.py index ccfd0306..a4c16312 100644 --- a/kcidev/api.py +++ b/kcidev/api.py @@ -822,13 +822,20 @@ def get_boot_issues(self, test_id, error_verbose=True): error_verbose, ) - def get_issue_list(self, origin=None, days=7): + def get_issue_list(self, origin=None, days=None, *, start_date=None, end_date=None): + """List issues for the last 7 days or an explicit pair of date boundaries. + + Dates use UTC midnight; timestamps must include a timezone. Explicit + boundaries cannot be combined with days. Invalid ranges raise KciDevError. + """ return self._dashboard_request( "Dashboard issue list request failed", dashboard_fetch_issue_list, origin, days, True, + start_date, + end_date, ) def get_issue(self, issue_id): diff --git a/kcidev/libs/dashboard.py b/kcidev/libs/dashboard.py index 91142637..acf396dc 100644 --- a/kcidev/libs/dashboard.py +++ b/kcidev/libs/dashboard.py @@ -1,10 +1,11 @@ import json import logging +import re import time import urllib from contextlib import contextmanager from contextvars import ContextVar -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from functools import wraps from urllib.parse import urlparse @@ -402,9 +403,46 @@ def dashboard_fetch_boot_issues(test_id, use_json, error_verbose): return dashboard_api_fetch(endpoint, {}, use_json, error_verbose=error_verbose) -def dashboard_fetch_issue_list(origin, days, use_json): +def _parse_issue_date(value, option): + """Parse a UTC calendar date or a timezone-aware ISO 8601 timestamp.""" + try: + if re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value): + return datetime.fromisoformat(value).replace(tzinfo=timezone.utc) + if not re.match(r"[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt ]", value): + raise ValueError + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.utcoffset() is None: + raise ValueError + return parsed + except ValueError as exc: + raise click.BadParameter( + "Expected YYYY-MM-DD or an ISO 8601 timestamp with a timezone " + "(Z or an explicit UTC offset).", + param_hint=option, + ) from exc + + +def dashboard_fetch_issue_list(origin, days, use_json, start_date=None, end_date=None): + if start_date is not None or end_date is not None: + if start_date is None or end_date is None: + raise click.UsageError("--start-date and --end-date must be used together") + if days is not None: + raise click.UsageError( + "An explicit date range cannot be combined with --days" + ) + start = _parse_issue_date(start_date, "--start-date") + end = _parse_issue_date(end_date, "--end-date") + else: + end = datetime.now(timezone.utc) + start = end - timedelta(days=7 if days is None else days) + if start > end: + raise click.UsageError("The start date cannot be later than the end date") + + # The issue endpoint uses Unix seconds, not the tree endpoint's date filters. + # Strings also retain fractional seconds in the dashboard's str | int schema. params = { - "interval_in_days": days, + "startTimestampInSeconds": str(start.timestamp()), + "endTimestampInSeconds": str(end.timestamp()), } if origin: params["filter_origin"] = origin diff --git a/kcidev/subcommands/results/__init__.py b/kcidev/subcommands/results/__init__.py index 62b582e7..eab8006c 100644 --- a/kcidev/subcommands/results/__init__.py +++ b/kcidev/subcommands/results/__init__.py @@ -932,6 +932,8 @@ def get_filtered_issues(origin, tree_name, giturl, branch, commit, arch): Examples: # Get issues kci-dev results issues --origin --days + # Get issues within exact UTC boundaries + kci-dev results issues --start-date 2026-08-01T00:00:00Z --end-date 2026-08-08T00:00:00Z # Get new issues for all checkouts kci-dev results issues --new --days --origin # Get new issues for a checkout @@ -953,9 +955,17 @@ def get_filtered_issues(origin, tree_name, giturl, branch, commit, arch): ) @click.option( "--days", - help="Provide a period of time in days to get results for", + help="Get results for the last N days (default: 5); cannot be combined with a date range", type=int, - default="5", + default=None, +) +@click.option( + "--start-date", + help="Issue listing start: YYYY-MM-DD (UTC midnight) or timezone-aware ISO 8601; requires --end-date", +) +@click.option( + "--end-date", + help="Issue listing end: YYYY-MM-DD (UTC midnight) or timezone-aware ISO 8601; requires --start-date", ) @click.option( "--new", @@ -1007,6 +1017,8 @@ def issues( ctx, origin, days, + start_date, + end_date, new, giturl, branch, @@ -1021,9 +1033,33 @@ def issues( boots, ): """Issues command handler""" + has_date_range = start_date is not None or end_date is not None + if has_date_range and any( + [ + new, + missing, + giturl, + branch, + commit, + git_folder, + latest, + arch, + tree, + builds, + boots, + ] + ): + raise click.UsageError( + "--start-date and --end-date are only supported for the unfiltered issue " + "listing (with optional --origin and --json)" + ) + if not has_date_range and days is None: + days = 5 if not new and not missing: if not any([giturl, branch, commit]): - data = dashboard_fetch_issue_list(origin, days, use_json) + data = dashboard_fetch_issue_list( + origin, days, use_json, start_date, end_date + ) print_issues(data, use_json) return if not all([giturl, branch, commit]): diff --git a/tests/test_issue_date_range.py b/tests/test_issue_date_range.py new file mode 100644 index 00000000..39c93bd1 --- /dev/null +++ b/tests/test_issue_date_range.py @@ -0,0 +1,187 @@ +import json +from datetime import datetime, timezone +from unittest.mock import Mock + +import pytest +from click.testing import CliRunner + +from kcidev import KciDevError, KernelCIClient +from kcidev.libs import dashboard +from kcidev.subcommands.results import issues + + +@pytest.fixture +def fetch(monkeypatch): + response = { + "issues": [{"id": "maestro:issue", "comment": "Build failure"}], + "extras": {"maestro:issue": {"tree_name": "mainline"}}, + } + fetch = Mock(return_value=response) + monkeypatch.setattr(dashboard, "dashboard_api_fetch", fetch) + return fetch + + +@pytest.mark.parametrize("use_json", [False, True]) +@pytest.mark.parametrize( + "start,end,expected_start,expected_end", + [ + ("2026-08-01", "2026-08-08", 1785542400, 1786147200), + ( + "2026-08-01T09:00:00+09:00", + "2026-08-07T20:00:00-04:00", + 1785542400, + 1786147200, + ), + ( + "2026-08-01T00:00:00.123456Z", + "2026-08-01T00:00:00.654321Z", + 1785542400.123456, + 1785542400.654321, + ), + ( + "2026-08-01T09:00:00+09:00", + "2026-08-01T00:00:00Z", + 1785542400, + 1785542400, + ), + ], +) +def test_cli_exact_issue_range( + fetch, use_json, start, end, expected_start, expected_end +): + args = ["--origin", "test", "--start-date", start, "--end-date", end] + if use_json: + args.append("--json") + result = CliRunner().invoke(issues, args) + + assert result.exit_code == 0, result.output + endpoint, params, json_mode = fetch.call_args.args + assert endpoint == "issue/" + assert params["filter_origin"] == "test" + assert float(params["startTimestampInSeconds"]) == expected_start + assert float(params["endTimestampInSeconds"]) == expected_end + assert "interval_in_days" not in params + assert json_mode is use_json + if use_json: + assert json.loads(result.output)[0]["id"] == "maestro:issue" + else: + assert "Build failure" in result.output + + +@pytest.mark.parametrize( + "args,message", + [ + (["--start-date", "2026-08-01"], "must be used together"), + (["--end-date", "2026-08-08"], "must be used together"), + (["--start-date", "invalid", "--end-date", "2026-08-08"], "--start-date"), + (["--start-date", "2026-08-01", "--end-date", "2026-02-30"], "--end-date"), + (["--start-date", "20260801", "--end-date", "2026-08-08"], "YYYY-MM-DD"), + (["--start-date", "", "--end-date", "2026-08-08"], "YYYY-MM-DD"), + ( + ["--start-date", "2026-08-01T00:00:00", "--end-date", "2026-08-08"], + "timezone", + ), + (["--start-date", "2026-08-08", "--end-date", "2026-08-01"], "later than"), + ( + [ + "--start-date", + "2026-08-01T00:00:00Z", + "--end-date", + "2026-08-01T01:00:00+09:00", + ], + "later than", + ), + ( + ["--start-date", "2026-08-01", "--end-date", "2026-08-08", "--days", "5"], + "--days", + ), + ], +) +def test_cli_rejects_invalid_ranges_without_request(fetch, args, message): + result = CliRunner().invoke(issues, args) + assert result.exit_code == 2, result.output + assert message in result.output + fetch.assert_not_called() + + +@pytest.mark.parametrize( + "mode", + [ + ["--new"], + ["--missing"], + ["--giturl", "mainline"], + ["--arch", "arm64"], + ["--latest"], + ], +) +def test_cli_rejects_ranges_in_modes_that_cannot_apply_them(fetch, mode): + result = CliRunner().invoke( + issues, ["--start-date", "2026-08-01", "--end-date", "2026-08-08", *mode] + ) + assert result.exit_code == 2, result.output + assert "unfiltered issue listing" in result.output + fetch.assert_not_called() + + +@pytest.fixture +def frozen_now(monkeypatch): + class FixedDatetime(datetime): + @classmethod + def now(cls, tz=None): + assert tz is timezone.utc + return datetime(2026, 8, 8, 12, 30, tzinfo=tz) + + monkeypatch.setattr(dashboard, "datetime", FixedDatetime) + + +@pytest.mark.parametrize( + "args,days", [([], 5), (["--days", "2"], 2), (["--days", "0"], 0)] +) +def test_cli_preserves_days_window(fetch, frozen_now, args, days): + result = CliRunner().invoke(issues, args) + assert result.exit_code == 0, result.output + params = fetch.call_args.args[1] + assert float(params["endTimestampInSeconds"]) == 1786192200 + assert float(params["startTimestampInSeconds"]) == 1786192200 - days * 86400 + + +def test_client_supports_exact_ranges(fetch): + data = KernelCIClient().get_issue_list( + start_date="2026-08-01", end_date="2026-08-08" + ) + assert data["issues"][0]["id"] == "maestro:issue" + params = fetch.call_args.args[1] + assert "filter_origin" not in params + assert float(params["startTimestampInSeconds"]) == 1785542400 + assert float(params["endTimestampInSeconds"]) == 1786147200 + + +@pytest.mark.parametrize("kwargs,days", [({}, 7), ({"days": 3}, 3)]) +def test_client_preserves_days_window(fetch, frozen_now, kwargs, days): + KernelCIClient().get_issue_list(**kwargs) + params = fetch.call_args.args[1] + assert float(params["endTimestampInSeconds"]) == 1786192200 + assert float(params["startTimestampInSeconds"]) == 1786192200 - days * 86400 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"start_date": "2026-08-01"}, + {"start_date": "2026-08-01", "end_date": "invalid"}, + {"start_date": "2026-08-08", "end_date": "2026-08-01"}, + {"start_date": "2026-08-01", "end_date": "2026-08-08", "days": 7}, + ], +) +def test_client_reports_range_errors(fetch, kwargs): + with pytest.raises(KciDevError): + KernelCIClient().get_issue_list(**kwargs) + fetch.assert_not_called() + + +def test_issue_range_help(): + result = CliRunner().invoke(issues, ["--help"]) + assert result.exit_code == 0 + assert "--start-date" in result.output + assert "--end-date" in result.output + assert "UTC midnight" in result.output