Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish validated E2E evidence as GitHub attachments and update its PR comment.
|
||||
|
||||
This script only runs from the trusted ``workflow_run`` publisher. It never
|
||||
checks out PR code: it accepts the small evidence artifact produced by the
|
||||
untrusted E2E workflow, validates its manifest and PNG bytes, uploads the
|
||||
approved files as GitHub attachments, and replaces the placeholder in the
|
||||
source PR's CI review comment with those attachment URLs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
API_BASE = "https://api.github.com"
|
||||
EVIDENCE_START = "<!-- hermes-e2e-evidence:start -->"
|
||||
EVIDENCE_END = "<!-- hermes-e2e-evidence:end -->"
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
MAX_FILES = 20
|
||||
MAX_FILE_BYTES = 5 * 1024 * 1024
|
||||
MAX_TOTAL_BYTES = 20 * 1024 * 1024
|
||||
MAX_DIMENSION = 8_000
|
||||
COMMENT_LOOKUP_ATTEMPTS = 6
|
||||
COMMENT_LOOKUP_DELAY_SECONDS = 2
|
||||
_SAFE_FILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.png$")
|
||||
_ATTACHMENT_URL = re.compile(r"^!\[[^\]\r\n]*\]\((https://github\.com/user-attachments/assets/[0-9a-fA-F-]+)\)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenceFile:
|
||||
"""One validated PNG and the label used when rendering the PR comment."""
|
||||
|
||||
filename: str
|
||||
label: str
|
||||
|
||||
|
||||
def _api_request(
|
||||
url: str,
|
||||
token: str,
|
||||
method: str = "GET",
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send one authenticated GitHub API request and return its JSON object."""
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "hermes-e2e-evidence-publisher",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request) as response:
|
||||
parsed = json.loads(response.read())
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(f"Expected an object from {url}")
|
||||
return parsed
|
||||
|
||||
|
||||
def _read_png(path: Path) -> bytes:
|
||||
"""Read a bounded PNG, rejecting corrupt and unexpectedly large images."""
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise ValueError(f"Evidence file is not a regular file: {path.name}")
|
||||
size = path.stat().st_size
|
||||
if size == 0 or size > MAX_FILE_BYTES:
|
||||
raise ValueError(f"Evidence file has invalid size: {path.name}")
|
||||
data = path.read_bytes()
|
||||
if not data.startswith(PNG_SIGNATURE) or len(data) < 24 or data[12:16] != b"IHDR":
|
||||
raise ValueError(f"Evidence file is not a PNG: {path.name}")
|
||||
width = int.from_bytes(data[16:20], "big")
|
||||
height = int.from_bytes(data[20:24], "big")
|
||||
if not 0 < width <= MAX_DIMENSION or not 0 < height <= MAX_DIMENSION:
|
||||
raise ValueError(f"Evidence image has invalid dimensions: {path.name}")
|
||||
return data
|
||||
|
||||
|
||||
def _manifest_files(manifest: dict[str, Any]) -> list[EvidenceFile]:
|
||||
"""Flatten a version-one manifest into ordered, reviewer-facing images."""
|
||||
if manifest.get("version") != 1:
|
||||
raise ValueError("Unsupported E2E evidence manifest version")
|
||||
|
||||
files: list[EvidenceFile] = []
|
||||
screenshots = manifest.get("screenshots", [])
|
||||
diffs = manifest.get("diffs", [])
|
||||
if not isinstance(screenshots, list) or not isinstance(diffs, list):
|
||||
raise ValueError("Evidence manifest lists are malformed")
|
||||
|
||||
for entry in screenshots:
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not isinstance(entry.get("file"), str):
|
||||
raise ValueError("Evidence screenshot entry is malformed")
|
||||
files.append(EvidenceFile(entry["file"], f"new screenshot: {entry['name']}"))
|
||||
|
||||
for entry in diffs:
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not isinstance(entry.get("diff"), str):
|
||||
raise ValueError("Evidence visual-diff entry is malformed")
|
||||
files.append(EvidenceFile(entry["diff"], f"visual diff: {entry['name']}"))
|
||||
for kind in ("actual", "expected"):
|
||||
value = entry.get(kind)
|
||||
if value is not None:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Evidence visual-diff companion is malformed")
|
||||
files.append(EvidenceFile(value, f"visual {kind}: {entry['name']}"))
|
||||
|
||||
names = [item.filename for item in files]
|
||||
if len(files) > MAX_FILES or len(set(names)) != len(names):
|
||||
raise ValueError("Evidence manifest has too many or duplicate files")
|
||||
if any(not _SAFE_FILE.fullmatch(name) for name in names):
|
||||
raise ValueError("Evidence manifest contains an unsafe filename")
|
||||
return files
|
||||
|
||||
|
||||
def load_evidence(evidence_dir: Path) -> tuple[list[EvidenceFile], dict[str, bytes]]:
|
||||
"""Load the manifest and return only the validated files it declares."""
|
||||
manifest_path = evidence_dir / "e2e-evidence.json"
|
||||
if not manifest_path.is_file() or manifest_path.is_symlink():
|
||||
raise ValueError("E2E evidence manifest is missing")
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("E2E evidence manifest is not JSON") from exc
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("E2E evidence manifest is not an object")
|
||||
|
||||
files = _manifest_files(manifest)
|
||||
payloads: dict[str, bytes] = {}
|
||||
total = 0
|
||||
for item in files:
|
||||
path = evidence_dir / item.filename
|
||||
if path.parent != evidence_dir:
|
||||
raise ValueError("Evidence file escaped its artifact directory")
|
||||
payload = _read_png(path)
|
||||
total += len(payload)
|
||||
if total > MAX_TOTAL_BYTES:
|
||||
raise ValueError("E2E evidence exceeds the total size limit")
|
||||
payloads[item.filename] = payload
|
||||
return files, payloads
|
||||
|
||||
|
||||
def render_evidence(files: list[EvidenceFile], attachment_urls: dict[str, str]) -> str:
|
||||
"""Render validated GitHub attachment URLs inside the review-comment marker."""
|
||||
blocks = [EVIDENCE_START]
|
||||
for item in files:
|
||||
url = attachment_urls.get(item.filename)
|
||||
if url is None:
|
||||
raise ValueError(f"Missing attachment URL for {item.filename}")
|
||||
blocks.extend((
|
||||
"<details>",
|
||||
f"<summary>{item.label}</summary>",
|
||||
"",
|
||||
f"",
|
||||
"",
|
||||
"</details>",
|
||||
))
|
||||
blocks.append(EVIDENCE_END)
|
||||
return "\n".join(blocks)
|
||||
|
||||
|
||||
def render_upload_failure(error: Exception) -> str:
|
||||
"""Render an escaped upload error inside the review-comment marker."""
|
||||
return "\n".join((
|
||||
EVIDENCE_START,
|
||||
"<sub>inline evidence upload failed.</sub>",
|
||||
"",
|
||||
f"<pre>{html.escape(str(error))}</pre>",
|
||||
EVIDENCE_END,
|
||||
))
|
||||
|
||||
|
||||
def replace_evidence_marker(comment: str, evidence: str) -> str:
|
||||
"""Replace exactly the pending-evidence region in a CI review comment."""
|
||||
pattern = re.compile(f"{re.escape(EVIDENCE_START)}.*?{re.escape(EVIDENCE_END)}", re.DOTALL)
|
||||
result, count = pattern.subn(evidence, comment, count=1)
|
||||
if count != 1:
|
||||
raise ValueError("CI review comment does not contain one evidence marker")
|
||||
return result
|
||||
|
||||
|
||||
def _find_review_comment(comments: object) -> dict[str, Any] | None:
|
||||
"""Find a live CI review comment only after it contains this marker."""
|
||||
if not isinstance(comments, list):
|
||||
raise ValueError("GitHub comments response is malformed")
|
||||
for item in comments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
body = str(item.get("body", ""))
|
||||
if body.startswith("<!-- hermes-ci-review-bot -->") and EVIDENCE_START in body and EVIDENCE_END in body:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _wait_for_review_comment(token: str, source_repo: str, pr_number: str) -> dict[str, Any] | None:
|
||||
"""Wait briefly for GitHub's comment API to expose the completed marker."""
|
||||
request = urllib.request.Request(
|
||||
f"{API_BASE}/repos/{source_repo}/issues/{pr_number}/comments?per_page=100",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "hermes-e2e-evidence-publisher",
|
||||
},
|
||||
)
|
||||
for attempt in range(COMMENT_LOOKUP_ATTEMPTS):
|
||||
with urllib.request.urlopen(request) as response:
|
||||
comment = _find_review_comment(json.loads(response.read()))
|
||||
if comment is not None:
|
||||
return comment
|
||||
if attempt + 1 < COMMENT_LOOKUP_ATTEMPTS:
|
||||
time.sleep(COMMENT_LOOKUP_DELAY_SECONDS)
|
||||
return None
|
||||
|
||||
|
||||
def upload_evidence(
|
||||
files: list[EvidenceFile],
|
||||
evidence_dir: Path,
|
||||
source_repo: str,
|
||||
session_token: str,
|
||||
) -> dict[str, str]:
|
||||
"""Upload validated files through gh-image and accept only attachment URLs."""
|
||||
environment = os.environ.copy()
|
||||
environment["GH_SESSION_TOKEN"] = session_token
|
||||
attachment_urls: dict[str, str] = {}
|
||||
for item in files:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"image",
|
||||
"--repo",
|
||||
source_repo,
|
||||
str(evidence_dir / item.filename),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
env=environment,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
output = "; ".join(
|
||||
value.strip()
|
||||
for value in (exc.stdout, exc.stderr)
|
||||
if value and value.strip()
|
||||
)
|
||||
message = f"Failed to upload {item.filename} with gh image (exit code {exc.returncode})"
|
||||
if output:
|
||||
message = f"{message}: {output}"
|
||||
print(message, file=sys.stderr)
|
||||
raise RuntimeError(message) from exc
|
||||
match = _ATTACHMENT_URL.fullmatch(result.stdout.strip())
|
||||
if match is None:
|
||||
raise ValueError(f"gh-image returned an invalid attachment reference for {item.filename}")
|
||||
attachment_urls[item.filename] = match.group(1)
|
||||
return attachment_urls
|
||||
|
||||
|
||||
def publish(
|
||||
token: str,
|
||||
source_repo: str,
|
||||
evidence_dir: Path,
|
||||
pr_number: str,
|
||||
session_token: str,
|
||||
) -> bool:
|
||||
"""Publish evidence and patch its source PR comment; false means nothing to show."""
|
||||
files, _ = load_evidence(evidence_dir)
|
||||
if not files:
|
||||
print("No inline E2E evidence to publish.")
|
||||
return False
|
||||
comment = _wait_for_review_comment(token, source_repo, pr_number)
|
||||
if comment is None:
|
||||
# A fork PR gets no CI review comment (the live poller needs a
|
||||
# write token there), so there is no marker to patch. The
|
||||
# evidence stays available in the workflow artifact.
|
||||
print(
|
||||
f"PR #{pr_number} has no CI review comment with an E2E evidence "
|
||||
"marker; the evidence stays in the workflow artifact."
|
||||
)
|
||||
return False
|
||||
try:
|
||||
attachment_urls = upload_evidence(
|
||||
files, evidence_dir, source_repo, session_token
|
||||
)
|
||||
except Exception as exc:
|
||||
body = replace_evidence_marker(
|
||||
str(comment.get("body", "")), render_upload_failure(exc)
|
||||
)
|
||||
_api_request(
|
||||
f"{API_BASE}/repos/{source_repo}/issues/comments/{comment['id']}",
|
||||
token,
|
||||
method="PATCH",
|
||||
payload={"body": body},
|
||||
)
|
||||
raise
|
||||
evidence = render_evidence(files, attachment_urls)
|
||||
body = replace_evidence_marker(str(comment.get("body", "")), evidence)
|
||||
_api_request(
|
||||
f"{API_BASE}/repos/{source_repo}/issues/comments/{comment['id']}",
|
||||
token,
|
||||
method="PATCH",
|
||||
payload={"body": body},
|
||||
)
|
||||
print(f"Published {len(files)} E2E evidence image attachment(s).")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--evidence-dir", type=Path, required=True)
|
||||
parser.add_argument("--source-repo", required=True)
|
||||
parser.add_argument("--pr-number", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
if not token:
|
||||
parser.error("GITHUB_TOKEN is required")
|
||||
session_token = os.environ.get("GH_SESSION_TOKEN", "")
|
||||
if not session_token:
|
||||
parser.error("GH_SESSION_TOKEN is required")
|
||||
publish(token, args.source_repo, args.evidence_dir, args.pr_number, session_token)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user