From 01822c13634427543ba881dbc1306f1607cc7389 Mon Sep 17 00:00:00 2001 From: enyst Date: Thu, 18 Jun 2026 13:35:14 +0000 Subject: [PATCH 1/4] Add REST API contract summaries to PR descriptions Co-authored-by: openhands --- ..._agent_server_rest_api_contract_summary.py | 334 ++++++++++++++++++ .../update_pr_body_with_rest_api_summary.py | 77 ++++ .../agent-server-rest-api-breakage.yml | 30 ++ ..._agent_server_rest_api_contract_summary.py | 239 +++++++++++++ 4 files changed, 680 insertions(+) create mode 100644 .github/scripts/generate_agent_server_rest_api_contract_summary.py create mode 100644 .github/scripts/update_pr_body_with_rest_api_summary.py create mode 100644 tests/cross/test_agent_server_rest_api_contract_summary.py diff --git a/.github/scripts/generate_agent_server_rest_api_contract_summary.py b/.github/scripts/generate_agent_server_rest_api_contract_summary.py new file mode 100644 index 0000000000..7bf3929294 --- /dev/null +++ b/.github/scripts/generate_agent_server_rest_api_contract_summary.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Generate a concise public REST API contract diff for PR descriptions.""" + +from __future__ import annotations + +import argparse +import difflib +import json +import re +from pathlib import Path +from typing import Any + +import check_agent_server_rest_api_breakage as rest_api + + +DEFAULT_MAX_DIFF_LINES = 120 +SCHEMA_REF_RE = re.compile(r"^#/components/schemas/(?P[^/]+)$") +SCHEMA_CONSTRAINT_KEYS = ( + "format", + "enum", + "const", + "default", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "minLength", + "maxLength", + "pattern", + "minItems", + "maxItems", + "uniqueItems", +) + + +def _copy_schema(schema: dict[str, Any]) -> dict[str, Any]: + return json.loads(json.dumps(schema)) + + +def _compact(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def _schema_ref_name(ref: str) -> str | None: + match = SCHEMA_REF_RE.match(ref) + return match.group("name") if match else None + + +def _collect_schema_refs(node: Any) -> set[str]: + refs: set[str] = set() + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and (name := _schema_ref_name(ref)): + refs.add(name) + for value in node.values(): + refs.update(_collect_schema_refs(value)) + elif isinstance(node, list): + for item in node: + refs.update(_collect_schema_refs(item)) + return refs + + +def _prune_unreferenced_schemas(schema: dict[str, Any]) -> dict[str, Any]: + schema = _copy_schema(schema) + schemas = schema.get("components", {}).get("schemas", {}) + if not isinstance(schemas, dict): + return schema + + used: set[str] = set() + pending = list(_collect_schema_refs(schema.get("paths", {}))) + while pending: + name = pending.pop() + if name in used or name not in schemas: + continue + used.add(name) + pending.extend(_collect_schema_refs(schemas[name]) - used) + + schema.setdefault("components", {})["schemas"] = { + name: schemas[name] for name in sorted(used) + } + return schema + + +def _schema_signature(schema: Any) -> str: + if not isinstance(schema, dict): + return _compact(schema) + + ref = schema.get("$ref") + if isinstance(ref, str) and (name := _schema_ref_name(ref)): + return name + + parts: list[str] = [] + schema_type = schema.get("type") + if schema_type is not None: + parts.append(f"type={_compact(schema_type)}") + + for union_key in ("oneOf", "anyOf", "allOf"): + values = schema.get(union_key) + if isinstance(values, list): + union_values = ",".join(_schema_signature(value) for value in values) + parts.append(f"{union_key}=[{union_values}]") + + items = schema.get("items") + if items is not None: + parts.append(f"items={_schema_signature(items)}") + + additional_properties = schema.get("additionalProperties") + if additional_properties is not None: + if isinstance(additional_properties, dict): + value = _schema_signature(additional_properties) + else: + value = _compact(additional_properties) + parts.append(f"additionalProperties={value}") + + for key in SCHEMA_CONSTRAINT_KEYS: + if key in schema: + parts.append(f"{key}={_compact(schema[key])}") + + if not parts and "properties" in schema: + parts.append("type=object") + + return " ".join(parts) if parts else "{}" + + +def _iter_media_schemas(content: Any): + if not isinstance(content, dict): + return + for media_type, media_object in sorted(content.items()): + if not isinstance(media_object, dict): + continue + yield media_type, media_object.get("schema", {}) + + +def _operation_contract_lines( + path: str, method: str, operation: dict[str, Any] +) -> list[str]: + label = f"{method.upper()} {path}" + details: list[str] = [] + if operation_id := operation.get("operationId"): + details.append(f"operationId={operation_id}") + if operation.get("deprecated") is True: + details.append("deprecated=true") + + suffix = f" {' '.join(details)}" if details else "" + lines = [f"operation {label}{suffix}"] + + parameters = operation.get("parameters", []) + if isinstance(parameters, list): + for parameter in parameters: + if not isinstance(parameter, dict): + continue + name = parameter.get("name", "") + location = parameter.get("in", "") + required = str(parameter.get("required") is True).lower() + signature = _schema_signature(parameter.get("schema", {})) + lines.append( + f"parameter {label} {location}:{name} " + f"required={required} schema={signature}" + ) + + request_body = operation.get("requestBody") + if isinstance(request_body, dict): + required = str(request_body.get("required") is True).lower() + for media_type, schema in _iter_media_schemas(request_body.get("content")): + lines.append( + f"requestBody {label} {media_type} " + f"required={required} schema={_schema_signature(schema)}" + ) + + responses = operation.get("responses", {}) + if isinstance(responses, dict): + for status_code, response in sorted(responses.items()): + if not isinstance(response, dict): + continue + media_schemas = list(_iter_media_schemas(response.get("content"))) + if not media_schemas: + lines.append(f"response {label} {status_code} no-content") + continue + for media_type, schema in media_schemas: + lines.append( + f"response {label} {status_code} {media_type} " + f"schema={_schema_signature(schema)}" + ) + + return lines + + +def _schema_contract_lines(name: str, schema: dict[str, Any]) -> list[str]: + schema_without_properties = { + key: value + for key, value in schema.items() + if key not in {"properties", "required", "title", "description"} + } + lines = [f"schema {name} {_schema_signature(schema_without_properties)}"] + + required = set(schema.get("required", [])) + properties = schema.get("properties", {}) + if not isinstance(properties, dict): + return lines + + for property_name, property_schema in sorted(properties.items()): + requirement = "required" if property_name in required else "optional" + lines.append( + f"schema {name} property {property_name} {requirement} " + f"schema={_schema_signature(property_schema)}" + ) + + return lines + + +def _flatten_public_contract(schema: dict[str, Any]) -> list[str]: + schema = _prune_unreferenced_schemas(schema) + lines: list[str] = [] + + paths = schema.get("paths", {}) + if isinstance(paths, dict): + for path, path_item in sorted(paths.items()): + if not isinstance(path_item, dict): + continue + for method, operation in sorted(path_item.items()): + if method not in rest_api.HTTP_METHODS or not isinstance( + operation, dict + ): + continue + lines.extend(_operation_contract_lines(path, method, operation)) + + schemas = schema.get("components", {}).get("schemas", {}) + if isinstance(schemas, dict): + for name, component_schema in sorted(schemas.items()): + if isinstance(component_schema, dict): + lines.extend(_schema_contract_lines(name, component_schema)) + + return sorted(set(lines)) + + +def generate_contract_diff( + previous_schema: dict[str, Any], + current_schema: dict[str, Any], + *, + previous_label: str, + current_label: str, + max_diff_lines: int = DEFAULT_MAX_DIFF_LINES, +) -> str: + previous_lines = _flatten_public_contract(previous_schema) + current_lines = _flatten_public_contract(current_schema) + if previous_lines == current_lines: + return "" + + diff_lines = list( + difflib.unified_diff( + previous_lines, + current_lines, + fromfile=previous_label, + tofile=current_label, + n=0, + lineterm="", + ) + ) + if len(diff_lines) > max_diff_lines: + omitted = len(diff_lines) - max_diff_lines + diff_lines = diff_lines[:max_diff_lines] + diff_lines.append(f"... diff truncated; {omitted} more line(s)") + + return "\n".join(diff_lines) + + +def generate_contract_summary( + previous_schema: dict[str, Any], + current_schema: dict[str, Any], + *, + base_ref: str, + max_diff_lines: int = DEFAULT_MAX_DIFF_LINES, +) -> str: + diff = generate_contract_diff( + previous_schema, + current_schema, + previous_label="base public OpenAPI", + current_label="head public OpenAPI", + max_diff_lines=max_diff_lines, + ) + if not diff: + return "" + + short_ref = base_ref[:12] + return ( + "### REST API contract changes\n\n" + f"Compared with base OpenAPI `{short_ref}` for public `/api/**` paths.\n\n" + "```diff\n" + f"{diff}\n" + "```\n" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate a PR-description summary for REST API contract changes." + ) + parser.add_argument("--base-ref", required=True, help="Git ref to compare against.") + parser.add_argument( + "--output", type=Path, required=True, help="Markdown output path." + ) + parser.add_argument( + "--max-diff-lines", + type=int, + default=DEFAULT_MAX_DIFF_LINES, + help="Maximum diff lines to include in the PR description.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + args.output.parent.mkdir(parents=True, exist_ok=True) + + previous_schema = rest_api._generate_openapi_for_git_ref(args.base_ref) + current_schema = rest_api._generate_current_openapi() + if previous_schema is None or current_schema is None: + args.output.write_text("") + return 0 + + previous_schema = rest_api._filter_public_rest_openapi(previous_schema) + current_schema = rest_api._filter_public_rest_openapi(current_schema) + summary = generate_contract_summary( + previous_schema, + current_schema, + base_ref=args.base_ref, + max_diff_lines=args.max_diff_lines, + ) + args.output.write_text(summary) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/update_pr_body_with_rest_api_summary.py b/.github/scripts/update_pr_body_with_rest_api_summary.py new file mode 100644 index 0000000000..459d8ecb98 --- /dev/null +++ b/.github/scripts/update_pr_body_with_rest_api_summary.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Insert the generated REST API summary into a PR body's Summary section.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + + +START_MARKER = "" +END_MARKER = "" +SUMMARY_HEADING_RE = re.compile(r"(?m)^##\s+Summary\s*$") +NEXT_HEADING_RE = re.compile(r"(?m)^##\s+") +GENERATED_BLOCK_RE = re.compile( + rf"\n?{re.escape(START_MARKER)}\n.*?\n{re.escape(END_MARKER)}\n?", + re.DOTALL, +) + + +def _summary_bounds(body: str) -> tuple[int, int] | None: + summary_match = SUMMARY_HEADING_RE.search(body) + if summary_match is None: + return None + + next_match = NEXT_HEADING_RE.search(body, summary_match.end()) + end = next_match.start() if next_match else len(body) + return summary_match.end(), end + + +def _generated_block(summary: str) -> str: + summary = summary.strip() + if not summary: + return "" + return f"{START_MARKER}\n{summary}\n{END_MARKER}" + + +def update_body(body: str, generated_summary: str) -> str: + bounds = _summary_bounds(body) + if bounds is None: + return body + + start, end = bounds + summary_section = body[start:end] + cleaned_section = GENERATED_BLOCK_RE.sub("\n", summary_section).rstrip() + block = _generated_block(generated_summary) + if block: + if cleaned_section.strip(): + replacement = f"{cleaned_section}\n\n{block}\n\n" + else: + replacement = f"\n\n{block}\n\n" + else: + replacement = f"{cleaned_section}\n" if cleaned_section else "\n" + + return f"{body[:start]}{replacement}{body[end:]}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Update a PR body with a generated REST API summary block." + ) + parser.add_argument("--body-file", type=Path, required=True) + parser.add_argument("--summary-file", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + body = args.body_file.read_text() + generated_summary = args.summary_file.read_text() + args.output.write_text(update_body(body, generated_summary)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/agent-server-rest-api-breakage.yml b/.github/workflows/agent-server-rest-api-breakage.yml index 80c33663d1..64d59ea6c2 100644 --- a/.github/workflows/agent-server-rest-api-breakage.yml +++ b/.github/workflows/agent-server-rest-api-breakage.yml @@ -155,3 +155,33 @@ jobs: body, }); } + + - name: Generate REST API contract PR summary + if: ${{ always() && github.event_name == 'pull_request' }} + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} + run: | + uv run --with packaging python .github/scripts/generate_agent_server_rest_api_contract_summary.py \ + --base-ref "$BASE_REF" \ + --output rest-api-contract-summary.md + touch rest-api-contract-summary.md + + - name: Update REST API contract summary in PR description + if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}" --jq .body > pr-body.md + python .github/scripts/update_pr_body_with_rest_api_summary.py \ + --body-file pr-body.md \ + --summary-file rest-api-contract-summary.md \ + --output updated-pr-body.md + if cmp -s pr-body.md updated-pr-body.md; then + echo "REST API contract summary in PR description is already current." + else + jq -n --rawfile body updated-pr-body.md '{body: $body}' \ + | gh api --method PATCH "repos/${REPOSITORY}/pulls/${PR_NUMBER}" --input - >/dev/null + echo "Updated REST API contract summary in PR description." + fi diff --git a/tests/cross/test_agent_server_rest_api_contract_summary.py b/tests/cross/test_agent_server_rest_api_contract_summary.py new file mode 100644 index 0000000000..403686683c --- /dev/null +++ b/tests/cross/test_agent_server_rest_api_contract_summary.py @@ -0,0 +1,239 @@ +"""Tests for generated REST API contract PR summaries.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def _load_script_module(name: str): + repo_root = Path(__file__).resolve().parents[2] + script_path = repo_root / ".github" / "scripts" / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, script_path) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +_breakage = _load_script_module("check_agent_server_rest_api_breakage") +_summary = _load_script_module("generate_agent_server_rest_api_contract_summary") +_body = _load_script_module("update_pr_body_with_rest_api_summary") + + +def _schema(paths: dict, schemas: dict) -> dict: + return { + "openapi": "3.1.0", + "paths": paths, + "components": {"schemas": schemas}, + } + + +def test_contract_summary_includes_operation_and_schema_additions(): + previous = _schema( + { + "/api/items": { + "get": { + "operationId": "list_items", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Item"} + } + } + } + }, + } + } + }, + { + "Item": { + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + } + }, + ) + current = _schema( + { + "/api/items": { + "get": previous["paths"]["/api/items"]["get"], + "post": { + "operationId": "create_item", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/CreateItem"} + } + }, + }, + "responses": {"204": {"description": "No Content"}}, + }, + } + }, + { + "CreateItem": { + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }, + "Item": previous["components"]["schemas"]["Item"], + }, + ) + + diff = _summary.generate_contract_diff( + previous, + current, + previous_label="base", + current_label="head", + ) + + assert "+operation POST /api/items operationId=create_item" in diff + assert ( + "+requestBody POST /api/items application/json required=true schema=CreateItem" + ) in diff + assert '+schema CreateItem property name required schema=type="string"' in diff + + +def test_contract_summary_ignores_unreferenced_schema_changes(): + previous = _schema( + { + "/api/items": { + "get": { + "operationId": "list_items", + "responses": {"204": {"description": "No Content"}}, + } + } + }, + {"InternalOnly": {"type": "object", "properties": {"old": {"type": "string"}}}}, + ) + current = _schema( + previous["paths"], + {"InternalOnly": {"type": "object", "properties": {"new": {"type": "string"}}}}, + ) + + assert ( + _summary.generate_contract_diff( + previous, + current, + previous_label="base", + current_label="head", + ) + == "" + ) + + +def test_contract_summary_shows_schema_property_modifications_as_diff(): + previous = _schema( + { + "/api/items": { + "get": { + "operationId": "list_items", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Item"} + } + } + } + }, + } + } + }, + { + "Item": { + "type": "object", + "properties": {"count": {"type": "integer"}}, + } + }, + ) + current = _schema( + previous["paths"], + { + "Item": { + "type": "object", + "properties": {"count": {"type": "string"}}, + } + }, + ) + + diff = _summary.generate_contract_diff( + previous, + current, + previous_label="base", + current_label="head", + ) + + assert '-schema Item property count optional schema=type="integer"' in diff + assert '+schema Item property count optional schema=type="string"' in diff + + +def test_update_body_inserts_summary_block_inside_summary_section(): + body = """HUMAN: + +human-written note + +--- + +AGENT: + +## Why + +Because. + +## Summary + +- Existing bullet. + +## How to Test + +pytest +""" + + updated = _body.update_body( + body, "### REST API contract changes\n\n```diff\n+ GET /api/x\n```" + ) + + assert _body.START_MARKER in updated + assert updated.index(_body.START_MARKER) > updated.index("## Summary") + assert updated.index(_body.END_MARKER) < updated.index("## How to Test") + assert "- Existing bullet." in updated + + +def test_update_body_replaces_or_removes_existing_summary_block(): + body = """AGENT: + +## Summary + +- Existing bullet. + + +old + + +## How to Test + +pytest +""" + + replaced = _body.update_body(body, "new") + assert "old" not in replaced + assert "new" in replaced + + removed = _body.update_body(replaced, "") + assert _body.START_MARKER not in removed + assert "new" not in removed + assert "- Existing bullet." in removed + + +def test_filter_public_rest_openapi_still_shared_with_breakage_check(): + schema = {"paths": {"/ready": {"get": {}}, "/api/items": {"get": {}}}} + + assert list(_breakage._filter_public_rest_openapi(schema)["paths"]) == [ + "/api/items" + ] From 9c7e8225fcc7e687c702841b4a099e3e5d58894c Mon Sep 17 00:00:00 2001 From: enyst Date: Thu, 18 Jun 2026 16:14:12 +0000 Subject: [PATCH 2/4] Scope REST API summary updates to agent section Co-authored-by: openhands --- .github/scripts/update_pr_body_with_rest_api_summary.py | 7 ++++++- .../cross/test_agent_server_rest_api_contract_summary.py | 8 ++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/scripts/update_pr_body_with_rest_api_summary.py b/.github/scripts/update_pr_body_with_rest_api_summary.py index 459d8ecb98..1fe78933f1 100644 --- a/.github/scripts/update_pr_body_with_rest_api_summary.py +++ b/.github/scripts/update_pr_body_with_rest_api_summary.py @@ -10,6 +10,7 @@ START_MARKER = "" END_MARKER = "" +AGENT_SECTION_RE = re.compile(r"(?m)^AGENT:\s*$") SUMMARY_HEADING_RE = re.compile(r"(?m)^##\s+Summary\s*$") NEXT_HEADING_RE = re.compile(r"(?m)^##\s+") GENERATED_BLOCK_RE = re.compile( @@ -19,7 +20,11 @@ def _summary_bounds(body: str) -> tuple[int, int] | None: - summary_match = SUMMARY_HEADING_RE.search(body) + agent_match = AGENT_SECTION_RE.search(body) + if agent_match is None: + return None + + summary_match = SUMMARY_HEADING_RE.search(body, agent_match.end()) if summary_match is None: return None diff --git a/tests/cross/test_agent_server_rest_api_contract_summary.py b/tests/cross/test_agent_server_rest_api_contract_summary.py index 403686683c..04c27d19ca 100644 --- a/tests/cross/test_agent_server_rest_api_contract_summary.py +++ b/tests/cross/test_agent_server_rest_api_contract_summary.py @@ -173,9 +173,11 @@ def test_contract_summary_shows_schema_property_modifications_as_diff(): assert '+schema Item property count optional schema=type="string"' in diff -def test_update_body_inserts_summary_block_inside_summary_section(): +def test_update_body_inserts_summary_block_inside_agent_summary_section(): body = """HUMAN: +## Summary + human-written note --- @@ -199,9 +201,11 @@ def test_update_body_inserts_summary_block_inside_summary_section(): body, "### REST API contract changes\n\n```diff\n+ GET /api/x\n```" ) + agent_summary = updated.rindex("## Summary") assert _body.START_MARKER in updated - assert updated.index(_body.START_MARKER) > updated.index("## Summary") + assert updated.index(_body.START_MARKER) > agent_summary assert updated.index(_body.END_MARKER) < updated.index("## How to Test") + assert updated.count("human-written note") == 1 assert "- Existing bullet." in updated From 9a10b65e8b3775938dea1f283bed7ddc7d0bed3f Mon Sep 17 00:00:00 2001 From: enyst Date: Thu, 18 Jun 2026 16:30:24 +0000 Subject: [PATCH 3/4] Preserve PR body newlines in REST summary updater Co-authored-by: openhands --- .../update_pr_body_with_rest_api_summary.py | 9 +++-- ..._agent_server_rest_api_contract_summary.py | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.github/scripts/update_pr_body_with_rest_api_summary.py b/.github/scripts/update_pr_body_with_rest_api_summary.py index 1fe78933f1..dc089ff9d0 100644 --- a/.github/scripts/update_pr_body_with_rest_api_summary.py +++ b/.github/scripts/update_pr_body_with_rest_api_summary.py @@ -41,6 +41,9 @@ def _generated_block(summary: str) -> str: def update_body(body: str, generated_summary: str) -> str: + if not generated_summary.strip() and START_MARKER not in body: + return body + bounds = _summary_bounds(body) if bounds is None: return body @@ -72,9 +75,9 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() - body = args.body_file.read_text() - generated_summary = args.summary_file.read_text() - args.output.write_text(update_body(body, generated_summary)) + body = args.body_file.read_text(newline="") + generated_summary = args.summary_file.read_text(newline="") + args.output.write_text(update_body(body, generated_summary), newline="") return 0 diff --git a/tests/cross/test_agent_server_rest_api_contract_summary.py b/tests/cross/test_agent_server_rest_api_contract_summary.py index 04c27d19ca..1faf6f07b5 100644 --- a/tests/cross/test_agent_server_rest_api_contract_summary.py +++ b/tests/cross/test_agent_server_rest_api_contract_summary.py @@ -235,6 +235,42 @@ def test_update_body_replaces_or_removes_existing_summary_block(): assert "- Existing bullet." in removed +def test_update_body_cli_preserves_crlf_when_empty_summary_is_noop( + tmp_path, + monkeypatch, +): + body = ( + "AGENT:\r\n\r\n" + "## Summary\r\n\r\n" + "- Existing bullet.\r\n\r\n" + "## How to Test\r\n\r\n" + "pytest\r\n" + ) + body_bytes = body.encode() + body_file = tmp_path / "body.md" + summary_file = tmp_path / "summary.md" + output_file = tmp_path / "output.md" + body_file.write_bytes(body_bytes) + summary_file.write_text("") + monkeypatch.setattr( + sys, + "argv", + [ + "update_pr_body_with_rest_api_summary.py", + "--body-file", + str(body_file), + "--summary-file", + str(summary_file), + "--output", + str(output_file), + ], + ) + + assert _body.main() == 0 + + assert output_file.read_bytes() == body_bytes + + def test_filter_public_rest_openapi_still_shared_with_breakage_check(): schema = {"paths": {"/ready": {"get": {}}, "/api/items": {"get": {}}}} From bd4d9175660fd2916057a7c20049eda7e30d286c Mon Sep 17 00:00:00 2001 From: enyst Date: Thu, 18 Jun 2026 16:37:12 +0000 Subject: [PATCH 4/4] Fix REST summary updater newline compatibility Co-authored-by: openhands --- .github/scripts/update_pr_body_with_rest_api_summary.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/scripts/update_pr_body_with_rest_api_summary.py b/.github/scripts/update_pr_body_with_rest_api_summary.py index dc089ff9d0..d1b984961b 100644 --- a/.github/scripts/update_pr_body_with_rest_api_summary.py +++ b/.github/scripts/update_pr_body_with_rest_api_summary.py @@ -75,9 +75,12 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() - body = args.body_file.read_text(newline="") - generated_summary = args.summary_file.read_text(newline="") - args.output.write_text(update_body(body, generated_summary), newline="") + with args.body_file.open(newline="") as body_file: + body = body_file.read() + with args.summary_file.open(newline="") as summary_file: + generated_summary = summary_file.read() + with args.output.open("w", newline="") as output_file: + output_file.write(update_body(body, generated_summary)) return 0