Skip to content

Commit f1e4d5e

Browse files
author
Krzysztof Dziedzic
committed
test: set up itk nightly runs
1 parent 94ad594 commit f1e4d5e

6 files changed

Lines changed: 470 additions & 85 deletions

File tree

.github/workflows/nightly.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Nightly ITK
2+
3+
on:
4+
push:
5+
branches:
6+
- '**'
7+
schedule:
8+
- cron: '0 2 * * *' # 2:00 AM UTC daily
9+
workflow_dispatch: # Allow manual execution
10+
11+
permissions:
12+
contents: write
13+
14+
jobs:
15+
nightly:
16+
name: Nightly ITK Run
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- name: Checkout code
21+
uses: actions/checkout@v6
22+
23+
- name: Install uv
24+
uses: astral-sh/setup-uv@v7
25+
26+
- name: Run Nightly ITK Tests
27+
run: bash run_itk.sh
28+
working-directory: itk
29+
env:
30+
A2A_SAMPLES_REVISION: feat/enable-building-subtests
31+
ITK_NIGHTLY_RUN: "True"
32+
33+
- name: Upload Results to Rolling Release
34+
uses: softprops/action-gh-release@v2
35+
with:
36+
tag_name: "nightly-metrics"
37+
prerelease: true
38+
files: |
39+
itk/itk_python.json
40+
env:
41+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

itk/main.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,20 @@ def extract_instruction(
100100
continue
101101
else:
102102
return inst
103-
return None
103+
def _get_text_from_part(part: Any) -> str | None:
104+
"""Safely extracts text string from a Part object supporting protobuf, pydantic, and raw dict."""
105+
if not part:
106+
return None
107+
if hasattr(part, 'HasField'):
108+
try:
109+
if part.HasField('text'):
110+
return part.text
111+
except ValueError:
112+
pass
113+
root = getattr(part, 'root', part)
114+
if isinstance(root, dict):
115+
return root.get('text')
116+
return getattr(root, 'text', None)
104117

105118

106119
def _extract_text_from_event(event: Any) -> list[str]:
@@ -157,6 +170,21 @@ async def _handle_call_agent_with_resubscribe(
157170
if hasattr(event, 'HasField') and event.HasField('task'):
158171
task_obj = event.task
159172

173+
if task_obj and hasattr(task_obj, 'history'):
174+
for msg in task_obj.history:
175+
if str(msg.role) in {'2', 'ROLE_AGENT', 'agent'}:
176+
for part in msg.parts:
177+
text = _get_text_from_part(part)
178+
if text and 'task-finished' in text:
179+
logger.info('Found task-finished in history, breaking loop!')
180+
results.append(text.replace('task-finished', ''))
181+
finished = True
182+
break
183+
if finished:
184+
break
185+
if finished:
186+
break
187+
160188
extracted_text = _extract_text_from_event(event)
161189
for text in extracted_text:
162190
processed_text = text.replace('task-finished', '')
@@ -174,11 +202,10 @@ async def _handle_call_agent_with_resubscribe(
174202
# Check stringified role to support protobuf enums (2 for ROLE_AGENT in v0.3 and v1.0)
175203
# as well as string descriptors from dict/JSON forms.
176204
if str(msg.role) in {'2', 'ROLE_AGENT', 'agent'}:
177-
results.extend(
178-
part.text.replace('task-finished', '')
179-
for part in msg.parts
180-
if part.text
181-
)
205+
for part in msg.parts:
206+
text = _get_text_from_part(part)
207+
if text:
208+
results.append(text.replace('task-finished', ''))
182209

183210
if not finished:
184211
logger.info('Canceling task %s after retrieval.', task_id)

itk/process_results.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env python3
2+
"""ITK Compatibility Metrics Processor.
3+
4+
Compiles test outcomes from raw JSON results, retrieves and aggregates historical
5+
runs from GitHub Release assets, and outputs the updated historical metrics log.
6+
"""
7+
8+
import datetime
9+
import json
10+
import logging
11+
import os
12+
import pathlib
13+
import sys
14+
import urllib.error
15+
import urllib.request
16+
17+
18+
# --- CONSTANTS ---
19+
RESULTS_FILE = 'raw_results.json'
20+
HISTORY_OUTPUT_FILE = 'itk_python.json'
21+
HISTORY_URL = 'https://github.com/a2aproject/a2a-python/releases/download/nightly-metrics/itk_python.json'
22+
SCENARIOS_FILE = 'scenarios.json'
23+
DEFAULT_HISTORY_LIMIT = 50
24+
25+
HTTP_STATUS_OK = 200
26+
HTTP_STATUS_NOT_FOUND = 404
27+
28+
# Configure logging to match standard ITK formatting
29+
logging.basicConfig(
30+
level=logging.INFO,
31+
)
32+
logger = logging.getLogger(__name__)
33+
34+
35+
def load_raw_results(filepath: str) -> dict:
36+
"""Loads the raw compatibility results from raw_results.json."""
37+
path = pathlib.Path(filepath)
38+
if not path.exists():
39+
logger.error('Results file %s not found.', filepath)
40+
raise SystemExit(1)
41+
42+
try:
43+
with path.open() as f:
44+
return json.load(f)
45+
except (OSError, json.JSONDecodeError):
46+
logger.exception('Error loading results JSON')
47+
raise SystemExit(1) from None
48+
49+
50+
def fetch_existing_history(url: str) -> list:
51+
"""Fetches the existing compatibility history from the GitHub release asset.
52+
53+
If the asset does not exist (HTTP 404), a fresh empty history list is returned.
54+
For all other network or server errors, the script exits with a non-zero status
55+
to prevent overwriting and losing historical metrics.
56+
"""
57+
try:
58+
req = urllib.request.Request( # noqa: S310
59+
url, headers={'User-Agent': 'Mozilla/5.0'}
60+
)
61+
with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310
62+
if response.status == HTTP_STATUS_OK:
63+
history = json.loads(response.read().decode('utf-8'))
64+
logger.info(
65+
'Successfully retrieved history. Current entries: %d',
66+
len(history),
67+
)
68+
return history
69+
logger.error(
70+
'Unexpected HTTP status when downloading existing history: %d',
71+
response.status,
72+
)
73+
raise SystemExit(1) # noqa: TRY301
74+
except urllib.error.HTTPError as e:
75+
if e.code == HTTP_STATUS_NOT_FOUND:
76+
logger.warning(
77+
'No existing history found (HTTP %d). Initializing fresh history.',
78+
e.code,
79+
)
80+
return []
81+
logger.exception(
82+
'HTTP error downloading existing history: %d. Aborting to preserve metrics.',
83+
e.code,
84+
)
85+
raise SystemExit(1) from None
86+
except Exception:
87+
logger.exception(
88+
'Failed to download existing history. Aborting to preserve metrics.'
89+
)
90+
raise SystemExit(1) from None
91+
92+
93+
def load_scenarios(filepath: str) -> list:
94+
"""Loads the list of tests from the scenarios.json definitions."""
95+
path = pathlib.Path(filepath)
96+
if not path.exists():
97+
logger.error('Scenarios file %s not found.', filepath)
98+
raise SystemExit(1)
99+
100+
try:
101+
with path.open() as f:
102+
data = json.load(f)
103+
return data['tests']
104+
except (OSError, json.JSONDecodeError, KeyError):
105+
logger.exception('Failed to load scenarios.json definitions')
106+
raise SystemExit(1) from None
107+
108+
109+
def save_history(filepath: str, history: list) -> None:
110+
"""Saves the updated history back to disk as a release asset candidate."""
111+
path = pathlib.Path(filepath)
112+
try:
113+
with path.open('w') as f:
114+
json.dump(history, f, indent=2)
115+
logger.info(
116+
'Successfully compiled and wrote nightly history to: %s',
117+
filepath,
118+
)
119+
except (OSError, TypeError):
120+
logger.exception('Error writing history file')
121+
sys.exit(1)
122+
123+
124+
def main() -> None:
125+
"""Orchestrates nightly ITK metrics processing and compiles rolling history."""
126+
# 1. Load raw compatibility results
127+
data = load_raw_results(RESULTS_FILE)
128+
all_passed = data.get('all_passed', False)
129+
results = data.get('results', {})
130+
131+
# 2. Fetch existing history from rolling release
132+
history = fetch_existing_history(HISTORY_URL)
133+
134+
# 3. Load scenarios list for base metadata
135+
scenarios_file = 'scenarios_full.json' if os.environ.get('ITK_NIGHTLY_RUN') == 'True' else 'scenarios.json'
136+
base_scenarios = load_scenarios(scenarios_file)
137+
# Merge definitions with current outcomes dynamically
138+
compiled_scenarios = []
139+
for name, details in results.items():
140+
# Extract the parent scenario name cleanly by splitting on the subtest suffix
141+
parent_name = name.split('-sub-')[0]
142+
143+
# Find the matching base scenario with an EXACT match!
144+
matched_base = None
145+
for base in base_scenarios:
146+
if parent_name == base['name']:
147+
matched_base = base
148+
break
149+
150+
if not matched_base:
151+
logger.warning('No matching base scenario found for result key: %s', name)
152+
continue
153+
154+
# Build the metadata-rich scenario record
155+
passed = False
156+
sdks = matched_base.get('sdks', [])
157+
edges = matched_base.get('edges')
158+
159+
if isinstance(details, dict):
160+
passed = details.get('passed', False)
161+
sdks = details.get('sdks', sdks)
162+
edges = details.get('edges', edges)
163+
elif isinstance(details, bool):
164+
passed = details
165+
166+
record = {
167+
'name': name,
168+
'sdks': sdks,
169+
'edges': edges,
170+
'protocols': matched_base.get('protocols'),
171+
'behavior': matched_base.get('behavior'),
172+
'traversal': matched_base.get('traversal', 'euler'),
173+
'passed': passed,
174+
}
175+
if 'streaming' in matched_base:
176+
record['streaming'] = matched_base['streaming']
177+
if 'build_subtests' in matched_base:
178+
record['build_subtests'] = matched_base['build_subtests']
179+
180+
compiled_scenarios.append(record)
181+
182+
# 4. Compile new run metadata
183+
new_run = {
184+
'timestamp': datetime.datetime.now(datetime.timezone.utc).isoformat(),
185+
'commit_sha': os.environ.get('GITHUB_SHA', 'local-dev'),
186+
'github_run_id': os.environ.get('GITHUB_RUN_ID', '0'),
187+
'all_passed': all_passed,
188+
'scenarios': compiled_scenarios,
189+
}
190+
191+
# 5. Merge and Prune rolling window
192+
history.append(new_run)
193+
history_limit = int(
194+
os.environ.get('ITK_HISTORY_LIMIT', str(DEFAULT_HISTORY_LIMIT))
195+
)
196+
if len(history) > history_limit:
197+
history = history[-history_limit:]
198+
logger.info('Pruned history to last %d entries.', history_limit)
199+
200+
# 6. Save candidates back to disk
201+
save_history(HISTORY_OUTPUT_FILE, history)
202+
sys.exit(0)
203+
204+
205+
if __name__ == '__main__':
206+
main()

0 commit comments

Comments
 (0)