Files
wehub-resource-sync 9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
Harness Compat / harness compat (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

891 lines
35 KiB
Python

from __future__ import annotations
import argparse
import json
import os
import re
import ssl
import statistics
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
SCHEMA_VERSION = 1
CI_WORKFLOW_FILE = 'ci.yml'
REPORT_MARKER = '<!-- ci-duration-report -->'
REPORT_LABEL = 'trigger:ci-duration-report'
BASELINE_MAIN_RUN_LIMIT = 30
BASELINE_PR_RUN_LIMIT = 60
BASELINE_COLLECTION_MAX_SECONDS = 90
MIN_BASELINE_SAMPLES = 10
WARNING_MIN_SECONDS = 60
SLOW_THRESHOLD_MULTIPLIER = 1.25
FAST_THRESHOLD_MULTIPLIER = 0.75
VERY_SLOW_MIN_SECONDS = 300
VERY_SLOW_THRESHOLD_MULTIPLIER = 1.5
JsonValue = None | bool | int | float | str | list['JsonValue'] | dict[str, 'JsonValue']
JsonObject = dict[str, JsonValue]
MetricAttributes = dict[str, bool | int | float | str]
@dataclass(frozen=True)
class WorkflowRunRecord:
repo: str
workflow_id: int
workflow_name: str
workflow_path: str
run_id: int
run_attempt: int
run_number: int
event: str
status: str
conclusion: str | None
head_branch: str | None
base_branch: str | None
head_sha: str
pr_numbers: list[int]
run_started_at: str | None
updated_at: str | None
duration_seconds: float | None
html_url: str
actor: str | None
@dataclass(frozen=True)
class StepRecord:
number: int
name: str
status: str
conclusion: str | None
started_at: str | None
completed_at: str | None
duration_seconds: float | None
@dataclass(frozen=True)
class JobRecord:
job_id: int
raw_name: str
job_family: str
job_signature: str
matrix_python: str | None
matrix_extra: str | None
conclusion: str | None
status: str
started_at: str | None
completed_at: str | None
duration_seconds: float | None
runner_name: str | None
runner_group_name: str | None
runner_class: str
html_url: str
steps: list[StepRecord]
@dataclass(frozen=True)
class Baseline:
sample_size: int
median_seconds: float
p75_seconds: float
p90_seconds: float
mad_seconds: float
@dataclass(frozen=True)
class ReportRow:
job_name: str
job_signature: str
duration_seconds: float | None
baseline: Baseline | None
delta_seconds: float | None
delta_percent: float | None
status: Literal['normal', 'fast', 'slow', 'very_slow', 'no_baseline', 'not_completed']
class GitHubClient:
def __init__(self, repo: str, token: str):
self.repo = repo
self.token = token
self.ssl_context = _ssl_context()
def request_json(self, path: str, *, method: str = 'GET', body: JsonObject | None = None) -> JsonValue:
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(
self._url(path),
data=data,
method=method,
headers={
'Accept': 'application/vnd.github+json',
'Authorization': f'Bearer {self.token}',
'Content-Type': 'application/json',
'X-GitHub-Api-Version': '2022-11-28',
},
)
with urllib.request.urlopen(request, timeout=30, context=self.ssl_context) as response:
response_body = response.read()
if not response_body:
return None
return json.loads(response_body)
def request_paginated(self, path: str, *, max_items: int | None = None) -> list[JsonObject]:
parsed = urllib.parse.urlsplit(path)
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
query = [item for item in query if item[0] not in {'page', 'per_page'}]
query.append(('per_page', '100'))
page = 1
results: list[JsonObject] = []
while True:
page_query = [*query, ('page', str(page))]
page_path = urllib.parse.urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path, urllib.parse.urlencode(page_query), '')
)
value = self.request_json(page_path)
page_items = _extract_page_items(value)
if not page_items:
return results
results.extend(page_items)
if max_items is not None and len(results) >= max_items:
return results[:max_items]
if len(page_items) < 100:
return results
page += 1
def _url(self, path: str) -> str:
if path.startswith('http://') or path.startswith('https://'):
return path
if not path.startswith('/'):
path = f'/repos/{self.repo}/{path}'
return f'https://api.github.com{path}'
def main() -> None:
parser = argparse.ArgumentParser(description='Collect and report GitHub Actions CI duration telemetry.')
subparsers = parser.add_subparsers(dest='command', required=True)
collect_parser = subparsers.add_parser('collect', help='Collect one CI workflow run and emit it to Logfire.')
collect_parser.add_argument('--run-id', default=os.getenv('CI_RUN_ID') or os.getenv('GITHUB_EVENT_WORKFLOW_RUN_ID'))
collect_parser.add_argument(
'--run-attempt', default=os.getenv('CI_RUN_ATTEMPT') or os.getenv('GITHUB_EVENT_WORKFLOW_RUN_ATTEMPT')
)
collect_parser.add_argument('--output', default='ci-duration-record.json')
collect_parser.add_argument('--skip-logfire', action='store_true')
report_parser = subparsers.add_parser('report', help='Post or update a PR CI duration report.')
report_parser.add_argument('--pr-number', default=os.getenv('PR_NUMBER'))
report_parser.add_argument('--head-sha', default=os.getenv('HEAD_SHA'))
report_parser.add_argument('--poll-seconds', type=int, default=0)
report_parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
client = _github_client_from_env()
if args.command == 'collect':
if args.run_id is None:
raise SystemExit('CI_RUN_ID is required')
run_attempt = int(args.run_attempt) if args.run_attempt else None
record = collect_run(client, int(args.run_id), run_attempt)
Path(args.output).write_text(json.dumps(record, indent=2, sort_keys=True) + '\n', encoding='utf-8')
print(f'Wrote CI duration record to {args.output}')
if not args.skip_logfire:
emit_logfire(record)
else:
if args.pr_number is None:
raise SystemExit('PR_NUMBER is required')
if args.head_sha is None:
raise SystemExit('HEAD_SHA is required')
body = build_pr_report(client, int(args.pr_number), args.head_sha, args.poll_seconds)
if args.dry_run:
print(body)
else:
upsert_pr_comment(client, int(args.pr_number), body)
def collect_run(client: GitHubClient, run_id: int, run_attempt: int | None) -> JsonObject:
run = _expect_object(client.request_json(f'actions/runs/{run_id}'), 'workflow run')
attempt = run_attempt or _expect_int(run.get('run_attempt'), 'run_attempt')
jobs = client.request_paginated(f'actions/runs/{run_id}/attempts/{attempt}/jobs')
workflow = normalize_workflow_run(client.repo, run, attempt)
job_records: list[JobRecord] = []
for job_object in jobs:
job = normalize_job(job_object)
if is_tracked_test_job(job):
job_records.append(job)
return {
'schema_version': SCHEMA_VERSION,
'fetched_at': _now(),
'workflow_run': workflow_to_json(workflow),
'jobs': [job_to_json(job) for job in job_records],
}
def build_pr_report(client: GitHubClient, pr_number: int, head_sha: str, poll_seconds: int) -> str:
run = wait_for_completed_ci_run(client, head_sha, poll_seconds)
if run is None:
return render_waiting_report(head_sha)
run_id = _expect_int(run.get('id'), 'run id')
run_attempt = _expect_int(run.get('run_attempt'), 'run_attempt')
current_record = collect_run(client, run_id, run_attempt)
current_jobs = [_job_from_json(job) for job in _expect_list(current_record['jobs'], 'jobs')]
baselines = collect_baselines(client, head_sha)
rows = [classify_job(job, baselines.get(job.job_signature)) for job in current_jobs]
workflow = _expect_object(current_record['workflow_run'], 'workflow_run')
return render_report(pr_number, head_sha, workflow, rows)
def wait_for_completed_ci_run(client: GitHubClient, head_sha: str, poll_seconds: int) -> JsonObject | None:
deadline = time.monotonic() + poll_seconds
while True:
run = find_latest_ci_run(client, head_sha)
if run is not None and run.get('status') == 'completed':
return run
if poll_seconds <= 0 or time.monotonic() >= deadline:
return None
time.sleep(20)
def find_latest_ci_run(client: GitHubClient, head_sha: str) -> JsonObject | None:
runs = client.request_paginated(f'actions/workflows/{CI_WORKFLOW_FILE}/runs?head_sha={head_sha}', max_items=10)
matching = [run for run in runs if run.get('head_sha') == head_sha]
if not matching:
return None
matching.sort(key=lambda run: str(run.get('created_at') or ''), reverse=True)
return matching[0]
def collect_baselines(client: GitHubClient, current_head_sha: str) -> dict[str, Baseline]:
try:
main_runs = client.request_paginated(
f'actions/workflows/{CI_WORKFLOW_FILE}/runs?branch=main&event=push&status=success',
max_items=BASELINE_MAIN_RUN_LIMIT,
)
pr_runs = client.request_paginated(
f'actions/workflows/{CI_WORKFLOW_FILE}/runs?event=pull_request&status=success',
max_items=BASELINE_PR_RUN_LIMIT,
)
except (TimeoutError, urllib.error.URLError) as exc:
print(f'Unable to collect baseline CI runs: {exc}', file=sys.stderr)
return {}
durations: dict[str, list[float]] = {}
seen_run_ids: set[int] = set()
baseline_deadline = time.monotonic() + BASELINE_COLLECTION_MAX_SECONDS
for run in [*main_runs, *pr_runs]:
if time.monotonic() >= baseline_deadline:
print('Baseline collection time budget exhausted; using partial baseline samples', file=sys.stderr)
break
run_id = _expect_int(run.get('id'), 'baseline run id')
if run_id in seen_run_ids or run.get('head_sha') == current_head_sha:
continue
seen_run_ids.add(run_id)
run_attempt = _expect_int(run.get('run_attempt'), 'baseline run_attempt')
try:
jobs = client.request_paginated(f'actions/runs/{run_id}/attempts/{run_attempt}/jobs')
except (TimeoutError, urllib.error.URLError) as exc:
print(f'Unable to collect baseline jobs for run {run_id}: {exc}', file=sys.stderr)
continue
for job_object in jobs:
job = normalize_job(job_object)
if job.conclusion == 'success' and job.duration_seconds is not None and is_tracked_test_job(job):
durations.setdefault(job.job_signature, []).append(job.duration_seconds)
return {
signature: compute_baseline(values)
for signature, values in durations.items()
if len(values) >= MIN_BASELINE_SAMPLES
}
def normalize_workflow_run(repo: str, run: JsonObject, run_attempt: int) -> WorkflowRunRecord:
run_started_at = _expect_optional_str(run.get('run_started_at'), 'run_started_at')
updated_at = _expect_optional_str(run.get('updated_at'), 'updated_at')
return WorkflowRunRecord(
repo=repo,
workflow_id=_expect_int(run.get('workflow_id'), 'workflow_id'),
workflow_name=_expect_str(run.get('name'), 'workflow name'),
workflow_path=_expect_str(run.get('path'), 'workflow path'),
run_id=_expect_int(run.get('id'), 'run id'),
run_attempt=run_attempt,
run_number=_expect_int(run.get('run_number'), 'run_number'),
event=_expect_str(run.get('event'), 'event'),
status=_expect_str(run.get('status'), 'status'),
conclusion=_expect_optional_str(run.get('conclusion'), 'conclusion'),
head_branch=_expect_optional_str(run.get('head_branch'), 'head_branch'),
base_branch=_expect_optional_str(run.get('base_ref'), 'base_ref'),
head_sha=_expect_str(run.get('head_sha'), 'head_sha'),
pr_numbers=_pull_request_numbers(run.get('pull_requests')),
run_started_at=run_started_at,
updated_at=updated_at,
duration_seconds=_duration_seconds(run_started_at, updated_at),
html_url=_expect_str(run.get('html_url'), 'html_url'),
actor=_actor_login(run.get('actor')),
)
def normalize_job(job: JsonObject) -> JobRecord:
raw_name = _expect_str(job.get('name'), 'job name')
started_at = _expect_optional_str(job.get('started_at'), 'job started_at')
completed_at = _expect_optional_str(job.get('completed_at'), 'job completed_at')
matrix_python, matrix_extra = parse_job_matrix(raw_name)
job_family = parse_job_family(raw_name)
runner_class = parse_runner_class(
_expect_optional_str(job.get('runner_group_name'), 'runner_group_name'),
_expect_optional_str(job.get('runner_name'), 'runner_name'),
_expect_list_or_none(job.get('labels'), 'labels'),
)
return JobRecord(
job_id=_expect_int(job.get('id'), 'job id'),
raw_name=raw_name,
job_family=job_family,
job_signature=job_signature(job_family, matrix_python, matrix_extra, runner_class),
matrix_python=matrix_python,
matrix_extra=matrix_extra,
conclusion=_expect_optional_str(job.get('conclusion'), 'job conclusion'),
status=_expect_str(job.get('status'), 'job status'),
started_at=started_at,
completed_at=completed_at,
duration_seconds=_duration_seconds(started_at, completed_at),
runner_name=_expect_optional_str(job.get('runner_name'), 'runner_name'),
runner_group_name=_expect_optional_str(job.get('runner_group_name'), 'runner_group_name'),
runner_class=runner_class,
html_url=_expect_str(job.get('html_url'), 'job html_url'),
steps=[normalize_step(step) for step in _expect_list_or_none(job.get('steps'), 'steps') or []],
)
def normalize_step(step: JsonValue) -> StepRecord:
step_object = _expect_object(step, 'step')
started_at = _expect_optional_str(step_object.get('started_at'), 'step started_at')
completed_at = _expect_optional_str(step_object.get('completed_at'), 'step completed_at')
return StepRecord(
number=_expect_int(step_object.get('number'), 'step number'),
name=_expect_str(step_object.get('name'), 'step name'),
status=_expect_str(step_object.get('status'), 'step status'),
conclusion=_expect_optional_str(step_object.get('conclusion'), 'step conclusion'),
started_at=started_at,
completed_at=completed_at,
duration_seconds=_duration_seconds(started_at, completed_at),
)
def workflow_to_json(workflow: WorkflowRunRecord) -> JsonObject:
pr_numbers: list[JsonValue] = [number for number in workflow.pr_numbers]
return {
'repo': workflow.repo,
'workflow_id': workflow.workflow_id,
'workflow_name': workflow.workflow_name,
'workflow_path': workflow.workflow_path,
'run_id': workflow.run_id,
'run_attempt': workflow.run_attempt,
'run_number': workflow.run_number,
'event': workflow.event,
'status': workflow.status,
'conclusion': workflow.conclusion,
'head_branch': workflow.head_branch,
'base_branch': workflow.base_branch,
'head_sha': workflow.head_sha,
'pr_numbers': pr_numbers,
'run_started_at': workflow.run_started_at,
'updated_at': workflow.updated_at,
'duration_seconds': workflow.duration_seconds,
'html_url': workflow.html_url,
'actor': workflow.actor,
}
def job_to_json(job: JobRecord) -> JsonObject:
return {
'job_id': job.job_id,
'raw_name': job.raw_name,
'job_family': job.job_family,
'job_signature': job.job_signature,
'matrix_python': job.matrix_python,
'matrix_extra': job.matrix_extra,
'conclusion': job.conclusion,
'status': job.status,
'started_at': job.started_at,
'completed_at': job.completed_at,
'duration_seconds': job.duration_seconds,
'runner_name': job.runner_name,
'runner_group_name': job.runner_group_name,
'runner_class': job.runner_class,
'html_url': job.html_url,
'steps': [step_to_json(step) for step in job.steps],
}
def step_to_json(step: StepRecord) -> JsonObject:
return {
'number': step.number,
'name': step.name,
'status': step.status,
'conclusion': step.conclusion,
'started_at': step.started_at,
'completed_at': step.completed_at,
'duration_seconds': step.duration_seconds,
}
def parse_job_matrix(job_name: str) -> tuple[str | None, str | None]:
match = re.fullmatch(r'test on (?P<python>[^ ]+) \((?P<extra>[^)]+)\)', job_name)
if match:
return match.group('python'), match.group('extra')
match = re.fullmatch(r'test examples on (?P<python>[^ ]+)', job_name)
if match:
return match.group('python'), 'examples'
return None, None
def parse_job_family(job_name: str) -> str:
if job_name.startswith('test on '):
return 'test'
if job_name.startswith('test examples on '):
return 'test-examples'
if job_name in {'lint', 'mypy', 'docs', 'coverage', 'check'}:
return job_name
return job_name
def is_tracked_test_job(job: JobRecord) -> bool:
return job.job_family == 'test'
def parse_runner_class(runner_group_name: str | None, runner_name: str | None, labels: list[JsonValue] | None) -> str:
label_values = [value for value in labels or [] if isinstance(value, str)]
lower_values = ' '.join([runner_group_name or '', runner_name or '', *label_values]).lower()
if 'depot' in lower_values:
return 'depot'
if 'ubicloud' in lower_values:
return 'ubicloud'
if 'github actions' in lower_values or 'ubuntu' in lower_values:
return 'github-hosted'
if 'self-hosted' in lower_values:
return 'self-hosted'
return 'unknown'
def job_signature(job_family: str, matrix_python: str | None, matrix_extra: str | None, runner_class: str) -> str:
parts = [f'job={job_family}', f'runner={runner_class}']
if matrix_python is not None:
parts.append(f'py={matrix_python}')
if matrix_extra is not None:
parts.append(f'extra={matrix_extra}')
return ' / '.join(parts)
def compute_baseline(values: list[float]) -> Baseline:
sorted_values = sorted(values)
median = statistics.median(sorted_values)
deviations = [abs(value - median) for value in sorted_values]
return Baseline(
sample_size=len(sorted_values),
median_seconds=median,
p75_seconds=percentile(sorted_values, 75),
p90_seconds=percentile(sorted_values, 90),
mad_seconds=statistics.median(deviations),
)
def percentile(sorted_values: list[float], percentile_value: int) -> float:
if len(sorted_values) == 1:
return sorted_values[0]
index = (len(sorted_values) - 1) * percentile_value / 100
lower = int(index)
upper = min(lower + 1, len(sorted_values) - 1)
fraction = index - lower
return sorted_values[lower] * (1 - fraction) + sorted_values[upper] * fraction
def classify_job(job: JobRecord, baseline: Baseline | None) -> ReportRow:
if job.conclusion != 'success' or job.duration_seconds is None:
return ReportRow(job.raw_name, job.job_signature, job.duration_seconds, baseline, None, None, 'not_completed')
if baseline is None:
return ReportRow(job.raw_name, job.job_signature, job.duration_seconds, None, None, None, 'no_baseline')
delta = job.duration_seconds - baseline.median_seconds
delta_percent = delta / baseline.median_seconds * 100 if baseline.median_seconds else None
slow_threshold = max(
baseline.p75_seconds * SLOW_THRESHOLD_MULTIPLIER, baseline.median_seconds + 2 * baseline.mad_seconds
)
very_slow_threshold = max(
baseline.p90_seconds * VERY_SLOW_THRESHOLD_MULTIPLIER,
baseline.median_seconds + 4 * baseline.mad_seconds,
)
if job.duration_seconds > very_slow_threshold or delta >= VERY_SLOW_MIN_SECONDS:
status: Literal['normal', 'fast', 'slow', 'very_slow', 'no_baseline', 'not_completed'] = 'very_slow'
elif job.duration_seconds > slow_threshold and delta >= WARNING_MIN_SECONDS:
status = 'slow'
elif job.duration_seconds < baseline.median_seconds * FAST_THRESHOLD_MULTIPLIER and delta <= -WARNING_MIN_SECONDS:
status = 'fast'
else:
status = 'normal'
return ReportRow(job.raw_name, job.job_signature, job.duration_seconds, baseline, delta, delta_percent, status)
def render_report(pr_number: int, head_sha: str, workflow: JsonObject, rows: list[ReportRow]) -> str:
slow_rows = [row for row in rows if row.status in {'slow', 'very_slow'}]
fast_rows = [row for row in rows if row.status == 'fast']
failed_rows = [row for row in rows if row.status == 'not_completed']
sorted_rows = sorted(
rows,
key=lambda row: (
row.status not in {'very_slow', 'slow', 'fast', 'not_completed'},
-(row.delta_seconds or 0),
row.job_name,
),
)
tracked_duration = sum(row.duration_seconds or 0 for row in rows)
run_url = _expect_str(workflow.get('html_url'), 'workflow html_url')
sha7 = head_sha[:7]
lines = [
REPORT_MARKER,
'## CI Duration Report',
'',
f'PR #{pr_number}, commit `{sha7}`: [CI run]({run_url})',
'',
'**Summary**',
f'- Tracked test jobs: {len(rows)}',
f'- Total tracked test job duration: {_format_seconds(tracked_duration)}',
f'- Slow jobs: {len(slow_rows)}',
f'- Fast jobs: {len(fast_rows)}',
f'- Failed/cancelled jobs: {len(failed_rows)}',
f'- Baseline: up to {BASELINE_MAIN_RUN_LIMIT} successful `main` CI runs and {BASELINE_PR_RUN_LIMIT} successful PR CI runs, matched by job signature and runner class',
f'- Minimum baseline sample: {MIN_BASELINE_SAMPLES} successful matching jobs',
f'- Slow threshold: duration > max(p75 * {SLOW_THRESHOLD_MULTIPLIER}, median + 2 * MAD), with at least {WARNING_MIN_SECONDS}s increase',
'',
'| Job | Duration | Baseline median | p75 | Delta | Status |',
'|---|---:|---:|---:|---:|---|',
]
for row in sorted_rows[:20]:
lines.append(
'| '
+ ' | '.join(
[
row.job_name,
_format_seconds(row.duration_seconds),
_format_seconds(row.baseline.median_seconds if row.baseline else None),
_format_seconds(row.baseline.p75_seconds if row.baseline else None),
_format_delta(row.delta_seconds, row.delta_percent),
row.status.replace('_', ' '),
]
)
+ ' |'
)
if len(sorted_rows) > 20:
lines.append(f'| ... | {len(sorted_rows) - 20} more jobs omitted | | | | |')
lines.extend(
[
'',
'<sub>Re-add the `trigger:ci-duration-report` label to refresh this report.</sub>',
]
)
return '\n'.join(lines)
def render_waiting_report(head_sha: str) -> str:
return '\n'.join(
[
REPORT_MARKER,
'## CI Duration Report — waiting for CI',
'',
f'No completed `CI` run was found for commit `{head_sha[:7]}` yet.',
'',
'<sub>Re-add the `trigger:ci-duration-report` label after CI completes, or rerun with a longer poll window.</sub>',
]
)
def upsert_pr_comment(client: GitHubClient, pr_number: int, body: str) -> None:
comments = client.request_paginated(f'issues/{pr_number}/comments')
existing_url: str | None = None
for comment in comments:
user = _expect_object(comment.get('user'), 'comment user')
if user.get('login') == 'github-actions[bot]' and str(comment.get('body') or '').startswith(REPORT_MARKER):
existing_url = _expect_str(comment.get('url'), 'comment url')
break
if existing_url:
client.request_json(existing_url, method='PATCH', body={'body': body})
print('Updated existing CI duration report comment')
else:
client.request_json(f'issues/{pr_number}/comments', method='POST', body={'body': body})
print('Created CI duration report comment')
def emit_logfire(record: JsonObject) -> None:
token = os.getenv('LOGFIRE_WRITE_TOKEN') or os.getenv('LOGFIRE_TOKEN')
if not token:
print('LOGFIRE_WRITE_TOKEN is not set; skipping Logfire emission')
return
try:
import logfire
except ImportError:
print('logfire is not installed; skipping Logfire emission')
return
logfire_base_url = os.getenv('LOGFIRE_URL')
advanced_options = logfire.AdvancedOptions(base_url=logfire_base_url) if logfire_base_url else None
logfire.configure(
token=token,
service_name='pydantic-ai-ci',
environment='github-actions',
console=False,
advanced=advanced_options,
)
workflow = _expect_object(record['workflow_run'], 'workflow_run')
jobs = _expect_list(record['jobs'], 'jobs')
tracked_test_duration_seconds = sum(
_expect_optional_float(_expect_object(job, 'job').get('duration_seconds'), 'duration_seconds') or 0
for job in jobs
)
test_run_duration_metric = logfire.metric_histogram(
'ci.test_run.tracked_duration',
unit='s',
description='Total duration of tracked CI test jobs in one workflow run.',
)
test_job_duration_metric = logfire.metric_histogram(
'ci.test_job.duration',
unit='s',
description='Duration of one tracked CI test matrix job.',
)
test_run_duration_metric.record(
tracked_test_duration_seconds,
metric_attributes(
{
'repo': workflow.get('repo'),
'workflow_name': workflow.get('workflow_name'),
'event': workflow.get('event'),
'base_branch': workflow.get('base_branch'),
'conclusion': workflow.get('conclusion'),
}
),
)
with logfire.span(
'ci.duration.test_run',
_tags=['ci-duration'],
schema_version=SCHEMA_VERSION,
repo=workflow.get('repo'),
workflow_name=workflow.get('workflow_name'),
run_id=workflow.get('run_id'),
run_attempt=workflow.get('run_attempt'),
event=workflow.get('event'),
conclusion=workflow.get('conclusion'),
head_branch=workflow.get('head_branch'),
base_branch=workflow.get('base_branch'),
head_sha=workflow.get('head_sha'),
pr_numbers=workflow.get('pr_numbers'),
duration_seconds=workflow.get('duration_seconds'),
tracked_test_jobs=len(jobs),
tracked_test_duration_seconds=tracked_test_duration_seconds,
html_url=workflow.get('html_url'),
):
for job in jobs:
job_object = _expect_object(job, 'job')
duration_seconds = _expect_optional_float(job_object.get('duration_seconds'), 'duration_seconds')
if duration_seconds is not None:
test_job_duration_metric.record(
duration_seconds,
metric_attributes(
{
'repo': workflow.get('repo'),
'workflow_name': workflow.get('workflow_name'),
'event': workflow.get('event'),
'base_branch': workflow.get('base_branch'),
'job_name': job_object.get('raw_name'),
'job_signature': job_object.get('job_signature'),
'matrix_python': job_object.get('matrix_python'),
'matrix_extra': job_object.get('matrix_extra'),
'runner_class': job_object.get('runner_class'),
'conclusion': job_object.get('conclusion'),
}
),
)
logfire.info(
'ci.duration.test_job',
_tags=['ci-duration'],
schema_version=SCHEMA_VERSION,
repo=workflow.get('repo'),
run_id=workflow.get('run_id'),
run_attempt=workflow.get('run_attempt'),
event=workflow.get('event'),
head_branch=workflow.get('head_branch'),
base_branch=workflow.get('base_branch'),
head_sha=workflow.get('head_sha'),
pr_numbers=workflow.get('pr_numbers'),
job_id=job_object.get('job_id'),
job_name=job_object.get('raw_name'),
job_family=job_object.get('job_family'),
job_signature=job_object.get('job_signature'),
matrix_python=job_object.get('matrix_python'),
matrix_extra=job_object.get('matrix_extra'),
runner_class=job_object.get('runner_class'),
conclusion=job_object.get('conclusion'),
duration_seconds=job_object.get('duration_seconds'),
html_url=job_object.get('html_url'),
)
logfire.force_flush()
def _github_client_from_env() -> GitHubClient:
repo = os.getenv('GITHUB_REPOSITORY')
token = os.getenv('GITHUB_TOKEN')
if not repo:
raise SystemExit('GITHUB_REPOSITORY is required')
if not token:
raise SystemExit('GITHUB_TOKEN is required')
return GitHubClient(repo, token)
def metric_attributes(attributes: JsonObject) -> MetricAttributes:
return {key: value for key, value in attributes.items() if isinstance(value, bool | int | float | str)}
def _ssl_context() -> ssl.SSLContext | None:
try:
import certifi
except ImportError:
return None
return ssl.create_default_context(cafile=certifi.where())
def _extract_page_items(value: JsonValue) -> list[JsonObject]:
if isinstance(value, list):
return [_expect_object(item, 'paginated item') for item in value]
if isinstance(value, dict):
for key in ('jobs', 'workflow_runs', 'comments'):
items = value.get(key)
if isinstance(items, list):
return [_expect_object(item, key) for item in items]
raise RuntimeError(f'Unexpected paginated response shape: {value!r}')
def _job_from_json(value: JsonValue) -> JobRecord:
job = _expect_object(value, 'job')
return JobRecord(
job_id=_expect_int(job.get('job_id'), 'job_id'),
raw_name=_expect_str(job.get('raw_name'), 'raw_name'),
job_family=_expect_str(job.get('job_family'), 'job_family'),
job_signature=_expect_str(job.get('job_signature'), 'job_signature'),
matrix_python=_expect_optional_str(job.get('matrix_python'), 'matrix_python'),
matrix_extra=_expect_optional_str(job.get('matrix_extra'), 'matrix_extra'),
conclusion=_expect_optional_str(job.get('conclusion'), 'conclusion'),
status=_expect_str(job.get('status'), 'status'),
started_at=_expect_optional_str(job.get('started_at'), 'started_at'),
completed_at=_expect_optional_str(job.get('completed_at'), 'completed_at'),
duration_seconds=_expect_optional_float(job.get('duration_seconds'), 'duration_seconds'),
runner_name=_expect_optional_str(job.get('runner_name'), 'runner_name'),
runner_group_name=_expect_optional_str(job.get('runner_group_name'), 'runner_group_name'),
runner_class=_expect_str(job.get('runner_class'), 'runner_class'),
html_url=_expect_str(job.get('html_url'), 'html_url'),
steps=[],
)
def _pull_request_numbers(value: JsonValue) -> list[int]:
if not isinstance(value, list):
return []
numbers: list[int] = []
for item in value:
if isinstance(item, dict):
number = item.get('number')
if isinstance(number, int):
numbers.append(number)
return numbers
def _actor_login(value: JsonValue) -> str | None:
if isinstance(value, dict):
login = value.get('login')
if isinstance(login, str):
return login
return None
def _duration_seconds(start: str | None, end: str | None) -> float | None:
if start is None or end is None:
return None
return (_parse_timestamp(end) - _parse_timestamp(start)).total_seconds()
def _parse_timestamp(value: str) -> datetime:
return datetime.fromisoformat(value.replace('Z', '+00:00'))
def _now() -> str:
return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
def _format_seconds(value: float | None) -> str:
if value is None:
return 'n/a'
seconds = int(round(value))
minutes, remainder = divmod(seconds, 60)
if minutes:
return f'{minutes}m {remainder:02d}s'
return f'{remainder}s'
def _format_delta(delta_seconds: float | None, delta_percent: float | None) -> str:
if delta_seconds is None or delta_percent is None:
return 'n/a'
sign = '+' if delta_seconds >= 0 else '-'
return f'{sign}{_format_seconds(abs(delta_seconds))} ({delta_percent:+.0f}%)'
def _expect_object(value: JsonValue, label: str) -> JsonObject:
if isinstance(value, dict):
return value
raise RuntimeError(f'Expected {label} to be an object, got {type(value).__name__}')
def _expect_list(value: JsonValue, label: str) -> list[JsonValue]:
if isinstance(value, list):
return value
raise RuntimeError(f'Expected {label} to be a list, got {type(value).__name__}')
def _expect_list_or_none(value: JsonValue, label: str) -> list[JsonValue] | None:
if value is None:
return None
return _expect_list(value, label)
def _expect_str(value: JsonValue, label: str) -> str:
if isinstance(value, str):
return value
raise RuntimeError(f'Expected {label} to be a string, got {type(value).__name__}')
def _expect_optional_str(value: JsonValue, label: str) -> str | None:
if value is None:
return None
return _expect_str(value, label)
def _expect_int(value: JsonValue, label: str) -> int:
if isinstance(value, int) and not isinstance(value, bool):
return value
raise RuntimeError(f'Expected {label} to be an integer, got {type(value).__name__}')
def _expect_optional_float(value: JsonValue, label: str) -> float | None:
if value is None:
return None
if isinstance(value, int | float) and not isinstance(value, bool):
return float(value)
raise RuntimeError(f'Expected {label} to be a number, got {type(value).__name__}')
if __name__ == '__main__':
try:
main()
except urllib.error.HTTPError as exc:
print(f'GitHub API request failed: HTTP {exc.code}\n{exc.read().decode()}', file=sys.stderr)
raise