Skip to content
Draft
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
27 changes: 24 additions & 3 deletions scripts/fetch-workflow-logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"""

import argparse
from datetime import datetime, timezone
import io
import json
import os
Expand Down Expand Up @@ -48,6 +49,25 @@ def _normalize_until(until: str | None) -> str | None:
return until + "T23:59:59Z"


def _normalize_since(since: str | None) -> str | None:
"""Normalize --since to a start-of-day timestamp when a date-only value is given."""
if since is None:
return None
# If already a full datetime (contains 'T'), use as-is
if "T" in since:
return since
# Date-only input (e.g. "2025-01-01"): treat as start of that UTC day
return since + "T00:00:00Z"


def _parse_iso8601_utc(value: str) -> datetime:
"""Parse ISO 8601 timestamp into timezone-aware UTC datetime."""
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.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
Expand All @@ -71,23 +91,24 @@ def _run_matches_conclusion(run: dict, conclusion: str | None) -> bool:
def _is_before_since_boundary(run: dict, since: str | None) -> bool:
if since is None:
return False
return run.get("created_at", "") < since
return _parse_iso8601_utc(run.get("created_at", "")) < _parse_iso8601_utc(since)


def _is_after_until_boundary(run: dict, until: str | None) -> bool:
if until is None:
return False
return run.get("created_at", "") > until
return _parse_iso8601_utc(run.get("created_at", "")) > _parse_iso8601_utc(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."""
since_normalized = _normalize_since(since)
until_normalized = _normalize_until(until)
runs = []
for batch in _iter_workflow_run_pages(repo=repo, workflow=workflow, token=token):
for run in batch:
if _is_before_since_boundary(run, since):
if _is_before_since_boundary(run, since_normalized):
# Runs are sorted newest-first; once we go past since, stop paging
return runs
if not _run_matches_conclusion(run, conclusion):
Expand Down
27 changes: 27 additions & 0 deletions tests/test_fetch_workflow_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,33 @@ 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_since_with_timezone_offset(monkeypatch):
module = _load_module()

def fake_github_api(path, token, accept="application/vnd.github+json"):
if path.endswith("page=1"):
return (
b'{"workflow_runs":['
b'{"id":2,"created_at":"2025-01-01T05:00:00Z","conclusion":"failure"},'
b'{"id":1,"created_at":"2025-01-01T04:30:00Z","conclusion":"failure"}'
b']}'
)
return 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="2025-01-01T00:00:00-05:00",
until=None,
conclusion="failure",
last=20,
)

assert [run["id"] for run in runs] == [2]


def test_conclusion_any_in_fetch_runs(monkeypatch, capsys):
module = _load_module()

Expand Down