From f16fae48f4144d69b5c0288deca1c013e5faefb2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:58:42 +0000 Subject: [PATCH 1/2] Fix timezone-aware --until filtering Compare workflow run timestamps as parsed UTC datetimes instead of lexicographic strings so timezone offsets are handled correctly. Add regression test for an --until value with +01:00 offset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/fetch-workflow-logs.py | 41 ++++++++++++++++++++++++++----- tests/test_fetch_workflow_logs.py | 26 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/scripts/fetch-workflow-logs.py b/scripts/fetch-workflow-logs.py index eda5a767..6b3ae106 100644 --- a/scripts/fetch-workflow-logs.py +++ b/scripts/fetch-workflow-logs.py @@ -18,6 +18,7 @@ """ import argparse +from datetime import datetime, timezone import io import json import os @@ -48,6 +49,26 @@ def _normalize_until(until: str | None) -> str | None: return until + "T23:59:59Z" +def _parse_iso8601_timestamp(value: str | None, label: str) -> datetime | None: + if value is None: + return None + + parsed_value = value + if parsed_value.endswith("Z"): + parsed_value = parsed_value[:-1] + "+00:00" + elif "T" not in parsed_value: + parsed_value = parsed_value + "T00:00:00+00:00" + + try: + parsed = datetime.fromisoformat(parsed_value) + except ValueError as e: + raise ValueError(f"Invalid {label} timestamp: {value}") from e + + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def _iter_workflow_run_pages(repo: str, workflow: str, token: str): """Yield workflow runs page-by-page in API order (newest-first).""" page = 1 @@ -68,31 +89,39 @@ def _run_matches_conclusion(run: dict, conclusion: str | None) -> bool: return run.get("conclusion") == conclusion -def _is_before_since_boundary(run: dict, since: str | None) -> bool: +def _is_before_since_boundary(run: dict, since: datetime | None) -> bool: if since is None: return False - return run.get("created_at", "") < since + run_created_at = _parse_iso8601_timestamp(run.get("created_at"), "run.created_at") + if run_created_at is None: + return False + return run_created_at < since -def _is_after_until_boundary(run: dict, until: str | None) -> bool: +def _is_after_until_boundary(run: dict, until: datetime | None) -> bool: if until is None: return False - return run.get("created_at", "") > until + run_created_at = _parse_iso8601_timestamp(run.get("created_at"), "run.created_at") + if run_created_at is None: + return False + return run_created_at > until def list_workflow_runs(repo: str, workflow: str, token: str, since: str | None, until: str | None, conclusion: str | None, last: int) -> list[dict]: """Return up to `last` workflow runs matching the filters.""" until_normalized = _normalize_until(until) + since_timestamp = _parse_iso8601_timestamp(since, "since") + until_timestamp = _parse_iso8601_timestamp(until_normalized, "until") runs = [] for batch in _iter_workflow_run_pages(repo=repo, workflow=workflow, token=token): for run in batch: if not _run_matches_conclusion(run, conclusion): continue - if _is_before_since_boundary(run, since): + if _is_before_since_boundary(run, since_timestamp): # Runs are sorted newest-first; once we go past since, stop paging return runs - if _is_after_until_boundary(run, until_normalized): + if _is_after_until_boundary(run, until_timestamp): continue runs.append(run) if len(runs) >= last: diff --git a/tests/test_fetch_workflow_logs.py b/tests/test_fetch_workflow_logs.py index c5787a3f..eeb5a112 100644 --- a/tests/test_fetch_workflow_logs.py +++ b/tests/test_fetch_workflow_logs.py @@ -67,6 +67,32 @@ def fake_github_api(path, token, accept="application/vnd.github+json"): assert [run["id"] for run in runs] == [2, 1] +def test_list_workflow_runs_excludes_runs_after_timezone_aware_until(monkeypatch): + module = _load_module() + + def fake_github_api(path, token, accept="application/vnd.github+json"): + return ( + b'{"workflow_runs":[' + b'{"id":42,"created_at":"2024-12-31T23:30:00Z","conclusion":"failure"}' + b']}' + if path.endswith("page=1") + else b'{"workflow_runs":[]}' + ) + + monkeypatch.setattr(module, "github_api", fake_github_api) + runs = module.list_workflow_runs( + repo="elastic/ai-github-actions", + workflow="ci.yml", + token="x", + since=None, + until="2025-01-01T00:00:00+01:00", + conclusion="failure", + last=20, + ) + + assert runs == [] + + def test_conclusion_any_in_fetch_runs(monkeypatch, capsys): module = _load_module() From 4221bf59f5305aa6ac363e70529df9ce234c136c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:58:43 +0000 Subject: [PATCH 2/2] Handle empty created_at in timestamp parser Treat empty timestamp strings as missing values to avoid crashing boundary checks, and add regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/fetch-workflow-logs.py | 2 +- tests/test_fetch_workflow_logs.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/fetch-workflow-logs.py b/scripts/fetch-workflow-logs.py index 6b3ae106..1639f6e8 100644 --- a/scripts/fetch-workflow-logs.py +++ b/scripts/fetch-workflow-logs.py @@ -50,7 +50,7 @@ def _normalize_until(until: str | None) -> str | None: def _parse_iso8601_timestamp(value: str | None, label: str) -> datetime | None: - if value is None: + if value is None or value.strip() == "": return None parsed_value = value diff --git a/tests/test_fetch_workflow_logs.py b/tests/test_fetch_workflow_logs.py index eeb5a112..20546ff8 100644 --- a/tests/test_fetch_workflow_logs.py +++ b/tests/test_fetch_workflow_logs.py @@ -93,6 +93,32 @@ def fake_github_api(path, token, accept="application/vnd.github+json"): assert runs == [] +def test_list_workflow_runs_handles_empty_created_at_with_until(monkeypatch): + module = _load_module() + + def fake_github_api(path, token, accept="application/vnd.github+json"): + return ( + b'{"workflow_runs":[' + b'{"id":7,"created_at":"","conclusion":"failure"}' + b']}' + if path.endswith("page=1") + else b'{"workflow_runs":[]}' + ) + + monkeypatch.setattr(module, "github_api", fake_github_api) + runs = module.list_workflow_runs( + repo="elastic/ai-github-actions", + workflow="ci.yml", + token="x", + since=None, + until="2025-01-01T00:00:00+01:00", + conclusion="failure", + last=20, + ) + + assert [run["id"] for run in runs] == [7] + + def test_conclusion_any_in_fetch_runs(monkeypatch, capsys): module = _load_module()