diff --git a/.github/workflows/sdk-update.yml b/.github/workflows/sdk-update.yml index e1595a2..65fcd54 100644 --- a/.github/workflows/sdk-update.yml +++ b/.github/workflows/sdk-update.yml @@ -293,6 +293,10 @@ jobs: GENERATION_STATUS: ${{ steps.status.outputs.result }} OLD_VERSION: ${{ steps.bump.outputs.old_version }} NEW_VERSION: ${{ steps.bump.outputs.new_version }} + # Passed through so the changelog-truncation suffix can deep-link + # back to this run when the oasdiff markdown is too big for the + # 64-KB PR body cap. + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | python scripts/build_pr_body.py \ --classification .sdk-update/classification.txt \ @@ -302,6 +306,7 @@ jobs: --generation-status "$GENERATION_STATUS" \ --old-version "$OLD_VERSION" \ --new-version "$NEW_VERSION" \ + --run-url "$RUN_URL" \ > .sdk-update/pr-body.md # ----- Force-push to auto/sdk-update ---------------------------------- diff --git a/scripts/build_pr_body.py b/scripts/build_pr_body.py index d03cb36..dd333b4 100755 --- a/scripts/build_pr_body.py +++ b/scripts/build_pr_body.py @@ -34,6 +34,13 @@ import sys from pathlib import Path +# GitHub caps PR body length at 65,536 characters; gh pr create/edit fails +# the whole call if we go over. Aim for a budget below that to leave +# headroom for trailing newlines, gh's own formatting, and any unicode +# expansion in the GraphQL payload. +GITHUB_PR_BODY_LIMIT = 65_536 +SAFE_BUDGET = 60_000 + def read_optional(path: str | None) -> str: if not path: @@ -44,6 +51,26 @@ def read_optional(path: str | None) -> str: return file_path.read_text(encoding="utf-8") +def truncate_at_line(content: str, max_chars: int, suffix: str = "") -> str: + """Truncate `content` to at most `max_chars` (counting `suffix`). + + Slices on a line boundary so we never cut mid-line, then appends + `suffix`. If `content` already fits, returns it unchanged. If the + suffix alone is bigger than `max_chars`, returns just the suffix + (better to surface the truncation notice than silently drop it). + """ + if len(content) <= max_chars: + return content + if len(suffix) >= max_chars: + return suffix + target = max_chars - len(suffix) + head = content[:target] + last_newline = head.rfind("\n") + if last_newline > 0: + head = head[:last_newline] + return head + suffix + + def parse_classification(text: str) -> tuple[str, str]: """Returns (level, reason). Tolerant of empty input.""" if not text.strip(): @@ -134,20 +161,10 @@ def build(args: argparse.Namespace) -> str: ) ) - # --- oasdiff markdown changelog ---------------------------------------- - changelog_md = read_optional(args.changelog_md).strip() - if changelog_md: - sections.append( - "
\n" - "API changelog (from oasdiff)\n\n" - f"{changelog_md}\n" - "
" - ) - # --- Changes since last review ----------------------------------------- delta = read_optional(args.since_last_review).strip() if delta: - sections.append( + delta_section = ( "
\n" "Changes since last review\n\n" "```diff\n" @@ -156,17 +173,85 @@ def build(args: argparse.Namespace) -> str: "
" ) else: - sections.append( + delta_section = ( "## Changes since last review\n\n_Initial PR — no prior review baseline._" ) # --- Footer ------------------------------------------------------------- - sections.append( + footer = ( "---\n" "_This PR was opened automatically by the `sdk-update` workflow._\n" "_Trigger: `workflow_dispatch`._" ) + # --- oasdiff markdown changelog (size-budgeted) ------------------------- + # We build this LAST so we can size it against the remaining budget. The + # changelog is the dominant size in practice (an oasdiff markdown for a + # major spec rewrite can be 100+ KB, well over GitHub's 65,536-char PR + # body limit). Everything before/after the changelog has a stable upper + # bound, so we treat the rest of the body as "fixed cost" and give the + # changelog whatever budget is left. + changelog_md = read_optional(args.changelog_md).strip() + fixed_sections = sections + [delta_section, footer] + # +2 chars per join boundary, +1 for the trailing newline we add at the + # bottom of the final body. Keep this estimation conservative — the + # cost of under-allocating is a softer notice; the cost of + # over-allocating is a hard PR-create failure. + fixed_size = sum(len(s) for s in fixed_sections) + 2 * len(fixed_sections) + 1 + + if changelog_md: + wrapper_overhead = ( + "
\n" + "API changelog (from oasdiff)\n\n" + "\n" + "
" + ) + # Reserve room for the wrapper plus the joiner between this section + # and the next one. + budget_for_changelog_content = ( + SAFE_BUDGET - fixed_size - len(wrapper_overhead) - 2 + ) + if budget_for_changelog_content > 0 and len(changelog_md) > budget_for_changelog_content: + suffix = ( + "\n\n_…changelog truncated to fit GitHub's PR body size limit." + ) + if args.run_url: + suffix += ( + f" Full changelog available as `.sdk-update/analysis/changelog.md`" + f" in the workflow run: {args.run_url}" + ) + else: + suffix += ( + " Full changelog available as `.sdk-update/analysis/changelog.md`" + " in the workflow run logs." + ) + suffix += "_" + changelog_md = truncate_at_line( + changelog_md, budget_for_changelog_content, suffix=suffix + ) + if budget_for_changelog_content > 0: + sections.append( + "
\n" + "API changelog (from oasdiff)\n\n" + f"{changelog_md}\n" + "
" + ) + else: + # Pathological: even the wrapper doesn't fit. Skip the changelog + # rather than fail the PR create. + note = ( + "_API changelog omitted to fit GitHub's PR body size limit._" + ) + if args.run_url: + note = ( + f"_API changelog omitted to fit GitHub's PR body size limit." + f" See workflow run: {args.run_url}_" + ) + sections.append(note) + + sections.append(delta_section) + sections.append(footer) + return "\n\n".join(sections) + "\n" @@ -184,6 +269,16 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("--old-version", default="") parser.add_argument("--new-version", default="") + parser.add_argument( + "--run-url", + default="", + help=( + "URL of this workflow run, used in the truncation notice when " + "the oasdiff changelog is too big to fit in the PR body. " + "Typically passed as ${{ github.server_url }}/${{ github.repository " + "}}/actions/runs/${{ github.run_id }}." + ), + ) return parser.parse_args()