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
11 changes: 10 additions & 1 deletion .github/ci-doctor-mq/schemas/pattern.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"last_seen",
"recent_run_urls",
"affected_prs",
"recent_timestamps"
"recent_timestamps",
"rerun_search_string"
],
"properties": {
"schema_version": {
Expand Down Expand Up @@ -94,6 +95,14 @@
"type": "string",
"format": "date-time"
}
},
"rerun_search_string": {
"type": [
"string",
"null"
],
"minLength": 1,
"description": "Stable, job-agnostic substring of the raw error (with absolute paths, line/column numbers, hex addresses, PIDs, timestamps, run IDs, commit SHAs, tmp dirs, and UUIDs stripped) that uniquely identifies this failure in run logs. Consumed by the workflow_rerunner (.github/scripts/workflow_rerun) to auto-rerun transient failures, similar to entries in errors_to_look_for.json. Populated only for transient, rerunnable categories (Flaky Test, Infrastructure, Network, External Service); null for all other categories where a plain rerun cannot fix the failure."
}
}
}
7 changes: 7 additions & 0 deletions .github/scripts/workflow_rerun/argument_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ def get_arguments() -> argparse.Namespace:
required=False,
help='.json file with the errors to look for in logs',
default=Path(__file__).resolve().parent.joinpath('errors_to_look_for.json'))
parser.add_argument('--patterns-dir',
type=Path,
required=False,
help='Directory with CI Doctor MQ pattern .json files (fetched from the '
'memory/ci-doctor-mq branch). Their "rerun_search_string" values are '
'used as additional errors to look for in logs',
default=None)
parser.add_argument('--dry-run',
required=False,
action='store_true',
Expand Down
50 changes: 48 additions & 2 deletions .github/scripts/workflow_rerun/log_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
import re
import tempfile
from pathlib import Path
from typing import TypedDict
from typing import Optional, TypedDict
from zipfile import ZipFile

from workflow_rerun.constants import LOGGER

# Sentinel ticket number used for errors sourced from CI Doctor MQ pattern files,
# which are not tied to a specific JIRA/GitHub ticket.
CI_DOCTOR_PATTERN_TICKET = 9999999


class LogFile(TypedDict):
file_name: str
Expand All @@ -24,11 +28,14 @@ class ErrorData(TypedDict):
class LogAnalyzer:
def __init__(self,
path_to_logs: Path,
path_to_errors_file: Path) -> None:
path_to_errors_file: Path,
patterns_dir: Optional[Path] = None) -> None:
self._path_to_errors_file = path_to_errors_file
self._patterns_dir = patterns_dir

self._errors_to_look_for: list[ErrorData] = []
self._collect_errors_to_look_for()
self._collect_errors_from_patterns()

self._log_dir = path_to_logs

Expand All @@ -52,6 +59,45 @@ def _collect_errors_to_look_for(self) -> None:
ticket=error_data['ticket'])
)

def _collect_errors_from_patterns(self) -> None:
"""
Collects additional errors to look for from CI Doctor MQ pattern files.

Each pattern .json file (fetched from the memory/ci-doctor-mq branch) may
contain a "rerun_search_string" field: a stable, searchable substring of a
transient failure's log output. When present and non-empty, it is used as an
additional error to look for, analogous to entries in errors_to_look_for.json.
Pattern-derived errors are not tied to a specific ticket, so they are recorded
with the CI_DOCTOR_PATTERN_TICKET sentinel ticket number.
"""
if self._patterns_dir is None:
return

if not self._patterns_dir.is_dir():
LOGGER.info(f'PATTERNS DIR {self._patterns_dir} DOES NOT EXIST, SKIPPING PATTERNS')
return

for pattern_file in sorted(self._patterns_dir.glob('*.json')):
try:
with open(file=pattern_file,
mode='r',
encoding='utf-8') as _pattern_file:
pattern_data = json.load(_pattern_file)
except (json.JSONDecodeError, OSError) as error:
LOGGER.warning(f'COULD NOT READ PATTERN FILE {pattern_file}: {error}')
continue

rerun_search_string = pattern_data.get('rerun_search_string')
if not isinstance(rerun_search_string, str) or not rerun_search_string.strip():
continue

LOGGER.info(f'ADDING RERUN SEARCH STRING FROM PATTERN {pattern_file.name}: '
f'"{rerun_search_string}"')
self._errors_to_look_for.append(
ErrorData(error_text=rerun_search_string,
ticket=CI_DOCTOR_PATTERN_TICKET)
)

def _collect_log_files(self) -> None:
"""
Collects the .txt log files from the log archive
Expand Down
9 changes: 6 additions & 3 deletions .github/scripts/workflow_rerun/rerunner.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from workflow_rerun.argument_parser import get_arguments
from workflow_rerun.constants import GITHUB_TOKEN, LOGGER
from workflow_rerun.log_analyzer import LogAnalyzer
from typing import Optional
from workflow_rerun.log_collector import collect_logs_for_run

def record_rerun_to_db(repository_full_name: str, run_id: int, ticket_number: int, rerunner_run_id: int, error_text: str):
Expand Down Expand Up @@ -68,7 +69,7 @@ def rerun_failed_jobs(repository_name: str, run_id: int, session: requests.Sessi
LOGGER.info(f'RUN RETRIGGERED SUCCESSFULLY: {run.html_url}')

def analyze_and_rerun(run, repository_name: str, run_id: int, rerunner_run_id: int,
errors_file: Path, is_dry_run: bool, session: requests.Session):
errors_file: Path, patterns_dir: Optional[Path], is_dry_run: bool, session: requests.Session):
with tempfile.TemporaryDirectory() as temp_dir:
logs_dir = Path(temp_dir)
collect_logs_for_run(
Expand All @@ -79,7 +80,8 @@ def analyze_and_rerun(run, repository_name: str, run_id: int, rerunner_run_id: i

log_analyzer = LogAnalyzer(
path_to_logs=logs_dir,
path_to_errors_file=errors_file
path_to_errors_file=errors_file,
patterns_dir=patterns_dir
)
log_analyzer.analyze()

Expand Down Expand Up @@ -108,6 +110,7 @@ def analyze_and_rerun(run, repository_name: str, run_id: int, rerunner_run_id: i
rerunner_run_id = args.rerunner_run_id
repository_name = args.repository_name
errors_file = args.errors_to_look_for_file
patterns_dir = args.patterns_dir
is_dry_run = args.dry_run
if is_dry_run:
LOGGER.info('RUNNING IN DRY RUN MODE. IF ERROR WILL BE FOUND, WILL NOT RETRIGGER')
Expand All @@ -133,4 +136,4 @@ def analyze_and_rerun(run, repository_name: str, run_id: int, rerunner_run_id: i
LOGGER.info(f'THERE ARE {run.run_attempt} ATTEMPTS ALREADY. NOT CHECKING LOGS AND NOT RETRIGGERING. EXITING')
sys.exit(0)

analyze_and_rerun(run, repository_name, run_id, rerunner_run_id, errors_file, is_dry_run, session)
analyze_and_rerun(run, repository_name, run_id, rerunner_run_id, errors_file, patterns_dir, is_dry_run, session)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"schema_version": "1.0",
"signature": "label empty or too long|Flaky Test",
"signature_hash": "aaaa1111bbbb2222",
"title": "Sample transient failure used for rerunner pattern tests",
"category": "Flaky Test",
"count": 2,
"first_seen": "2026-07-01T10:00:00Z",
"last_seen": "2026-07-13T10:00:00Z",
"recent_run_urls": [
"https://github.com/openvinotoolkit/openvino/actions/runs/29268143021"
],
"affected_prs": [],
"recent_timestamps": [
"2026-07-13T10:00:00Z"
],
"rerun_search_string": "label empty or too long"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"schema_version": "1.0",
"signature": "assertion failed in unit test|Code Issue",
"signature_hash": "cccc3333dddd4444",
"title": "Deterministic failure that must never trigger a rerun",
"category": "Code Issue",
"count": 1,
"first_seen": "2026-07-10T10:00:00Z",
"last_seen": "2026-07-10T10:00:00Z",
"recent_run_urls": [
"https://github.com/openvinotoolkit/openvino/actions/runs/29268143022"
],
"affected_prs": [],
"recent_timestamps": [
"2026-07-10T10:00:00Z"
],
"rerun_search_string": null
}
28 changes: 28 additions & 0 deletions .github/scripts/workflow_rerun/tests/gh_test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copyright (C) 2018-2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

"""
Shared helpers for live-GitHub integration tests
"""

from typing import Optional

from github.Repository import Repository
from github.WorkflowRun import WorkflowRun


def find_failed_run_with_logs(gh_repo: Repository,
created_filter: str,
max_candidates: int = 20) -> Optional[WorkflowRun]:
"""
Some workflow runs reported as "failure" never actually start any jobs
(e.g. the workflow file itself is invalid), so they have no logs to
download/analyze. This looks through the most recent failed runs and
returns the first one that has at least one failed job, or None if no
such run is found among the checked candidates.
"""
candidate_runs = gh_repo.get_workflow_runs(status='failure', created=created_filter)
for candidate_run in candidate_runs[:max_candidates]:
if any(job.conclusion == 'failure' for job in candidate_run.jobs()):
return candidate_run
return None
9 changes: 7 additions & 2 deletions .github/scripts/workflow_rerun/tests/integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from workflow_rerun.log_collector import collect_logs_for_run
from workflow_rerun.rerunner import analyze_and_rerun

from workflow_rerun.tests.gh_test_utils import find_failed_run_with_logs


class IntegrationTest(unittest.TestCase):
"""
Expand Down Expand Up @@ -54,8 +56,10 @@ def setUpClass(cls) -> None:
gh_repo = cls.github.get_repo(full_name_or_id='openvinotoolkit/openvino')

oldest_allowed_date = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
cls.wf_run = gh_repo.get_workflow_runs(status='failure',
created=f">={oldest_allowed_date}")[0]
cls.wf_run = find_failed_run_with_logs(gh_repo=gh_repo,
created_filter=f">={oldest_allowed_date}",
max_candidates=50)

print(f'Workflow run for testing: {cls.wf_run}', flush=True)

def setUp(self):
Expand Down Expand Up @@ -113,6 +117,7 @@ def fake_collect_logs_for_run(*, run, logs_dir: Path, session):
run_id=run_id,
rerunner_run_id=rerunner_run_id,
errors_file=self.errors_to_look_for_file,
patterns_dir=None,
is_dry_run=False,
session=mock_session
)
Expand Down
60 changes: 59 additions & 1 deletion .github/scripts/workflow_rerun/tests/log_analyzer_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pathlib import Path


from workflow_rerun.log_analyzer import LogAnalyzer
from workflow_rerun.log_analyzer import CI_DOCTOR_PATTERN_TICKET, LogAnalyzer


class LogAnalyzerTest(unittest.TestCase):
Expand All @@ -29,6 +29,10 @@ def setUp(self) -> None:
self.errors_to_look_for_file = self._cwd.parent.joinpath(
'errors_to_look_for.json'
)
self.empty_errors_file = self._cwd.joinpath('data').joinpath(
'empty_errors.json'
)
self.patterns_dir = self._cwd.joinpath('data').joinpath('patterns')

def test_log_analyzer_instantiation(self) -> None:
"""
Expand Down Expand Up @@ -105,3 +109,57 @@ def test_analyzer_wo_error(self) -> None:
)
analyzer.analyze()
self.assertFalse(analyzer.found_matching_error)

def test_patterns_loaded_from_directory(self) -> None:
"""
Ensure LogAnalyzer loads rerun_search_string values from CI Doctor MQ
pattern files (tagged with the CI_DOCTOR_PATTERN_TICKET sentinel ticket)
and skips patterns whose rerun_search_string is null.
"""
analyzer = LogAnalyzer(
path_to_logs=self.logs_dir_wo_error,
path_to_errors_file=self.empty_errors_file,
patterns_dir=self.patterns_dir,
)

pattern_errors = analyzer._errors_to_look_for
self.assertEqual(
len(pattern_errors),
1,
'Only the pattern with a non-null rerun_search_string should be loaded',
)
self.assertEqual(pattern_errors[0]['error_text'], 'label empty or too long')
self.assertEqual(
pattern_errors[0]['ticket'],
CI_DOCTOR_PATTERN_TICKET,
'Pattern-derived errors must carry the CI_DOCTOR_PATTERN_TICKET sentinel',
)

def test_analyzer_matches_pattern_search_string(self) -> None:
"""
Ensure LogAnalyzer can find an error using a rerun_search_string coming
from a CI Doctor MQ pattern file, reporting the sentinel ticket for it.
"""
analyzer = LogAnalyzer(
path_to_logs=self.logs_dir_with_error,
path_to_errors_file=self.empty_errors_file,
patterns_dir=self.patterns_dir,
)
analyzer.analyze()
self.assertTrue(analyzer.found_matching_error)
self.assertEqual(analyzer.found_error_ticket, CI_DOCTOR_PATTERN_TICKET)
self.assertEqual(analyzer.matched_error_text, 'label empty or too long')

def test_missing_patterns_dir_is_ignored(self) -> None:
"""
Ensure a non-existent patterns_dir does not break analysis and simply
adds no pattern-derived errors.
"""
analyzer = LogAnalyzer(
path_to_logs=self.logs_dir_wo_error,
path_to_errors_file=self.empty_errors_file,
patterns_dir=self._cwd.joinpath('data').joinpath('does_not_exist'),
)
self.assertEqual(analyzer._errors_to_look_for, [])
analyzer.analyze()
self.assertFalse(analyzer.found_matching_error)
6 changes: 4 additions & 2 deletions .github/scripts/workflow_rerun/tests/log_collector_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from requests.packages.urllib3.util.retry import Retry

from workflow_rerun.log_collector import collect_logs_for_run
from workflow_rerun.tests.gh_test_utils import find_failed_run_with_logs


class LogCollectorTest(unittest.TestCase):
Expand Down Expand Up @@ -46,8 +47,9 @@ def setUpClass(cls) -> None:
created=f">={oldest_allowed_date}")[0]
print(f'Successful workflow run for testing: {cls.successful_workflow_run}', flush=True)

cls.failed_workflow_run = gh_repo.get_workflow_runs(status='failure',
created=f">={oldest_allowed_date}")[0]
cls.failed_workflow_run = find_failed_run_with_logs(gh_repo=gh_repo,
created_filter=f">={oldest_allowed_date}",
max_candidates=50)
print(f'Failed workflow run for testing: {cls.failed_workflow_run}', flush=True)

def setUp(self):
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci-doctor-mq.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading