feat: consolidate chat memories with caps (experiment) #6500
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Posts a docs-preview comment on docs PRs. For every changed page with a | |
| # docs/manifest.json route it lists a preview link (coder.com/docs/@<branch>, | |
| # branch URL-encoded) and a Markdown checkbox. Checkbox state round-trips | |
| # across pushes but resets when a page's content changes, so a checked box | |
| # means "reviewed the current revision." Pages with no manifest route | |
| # (docs/.style/** tooling, or not-yet-navigable pages) are dropped, since | |
| # they 404 on the docs site. The comment is updated in place on later | |
| # pushes and deleted when nothing previewable remains. | |
| # | |
| # Changed images get an informational section (no checkboxes: the image | |
| # changed, the embedding pages did not). Each image lists the manifest- | |
| # routed pages that embed it; mapping needs the referencing Markdown on | |
| # disk, so the job checks out the PR head. An image no navigable page | |
| # embeds (e.g. an icon referenced only from manifest.json) is still listed | |
| # on its own, so an image-only PR always gets a comment. | |
| name: docs-preview | |
| on: | |
| pull_request: | |
| types: | |
| - opened | |
| - synchronize | |
| - reopened | |
| paths: | |
| - "docs/**" | |
| # docs/.style/** is contributor tooling that never deploys; skipping | |
| # .style-only PRs avoids an empty preview list. Mixed PRs still run, | |
| # and the selection logic below filters .style out. | |
| - "!docs/.style/**" | |
| concurrency: | |
| group: docs-preview-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| jobs: | |
| docs-preview: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| # Job-level permissions replace the workflow defaults, so contents: | |
| # read is repeated here for the checkout below. | |
| contents: read | |
| pull-requests: write # needed for commenting on PRs | |
| steps: | |
| # Checked out only to resolve image references (relative Markdown/HTML | |
| # links) to their pages. Pinned to the PR head so refs and | |
| # docs/manifest.json match the file list; sparse to docs/, the only | |
| # tree this job reads. | |
| - name: Check out docs sources | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| persist-credentials: false | |
| sparse-checkout: docs | |
| - name: Post docs preview comment | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BRANCH: ${{ github.event.pull_request.head.ref }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| # DOCS_PREVIEW_MARKER locates this workflow's own comments; | |
| # STATE_PREFIX carries the last-seen `path -> blob sha` map. Keep | |
| # map_doc_path, the manifest filter, and the carryover logic in | |
| # sync with test-docs-preview-mapper.sh. | |
| DOCS_PREVIEW_MARKER='<!-- docs-preview -->' | |
| STATE_PREFIX='docs-preview-state:' | |
| # Bound the always-rendered image section: at most | |
| # IMAGE_SECTION_BUDGET bytes and IMAGE_PAGES_MAX pages per image, | |
| # with "and N more" notes on overflow. Only matters in pathological | |
| # cases. Keep in sync with test-docs-preview-mapper.sh. | |
| IMAGE_SECTION_BUDGET=20000 | |
| IMAGE_PAGES_MAX=25 | |
| # Returns IDs of github-actions[bot] comments on the PR whose | |
| # body contains DOCS_PREVIEW_MARKER. | |
| list_docs_preview_comments() { | |
| gh api --paginate \ | |
| "repos/${REPO}/issues/${PR_NUMBER}/comments" \ | |
| --jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"${DOCS_PREVIEW_MARKER}\")) | .id" | |
| } | |
| # Deletes the existing comment (existing_id) and exits 0 so no | |
| # stale dead-link remains. A failed delete is cosmetic: log and | |
| # exit clean, next push retries. (The upsert path propagates | |
| # failures instead, to avoid duplicate comments.) | |
| cleanup_stale_and_exit() { | |
| if [ -n "$existing_id" ]; then | |
| if gh api --method DELETE \ | |
| "repos/${REPO}/issues/comments/${existing_id}"; then | |
| echo "Deleted stale docs preview comment (id=${existing_id})." | |
| else | |
| echo "Failed to delete stale docs preview comment (id=${existing_id}); leaving in place. This is usually a transient API error, and the next push retries the cleanup." >&2 | |
| fi | |
| fi | |
| exit 0 | |
| } | |
| # Maps a repo path to the docs site URL path. | |
| # docs/README.md -> "" (docs root) | |
| # docs/<dir>/index.md -> "<dir>" (directory index) | |
| # docs/<dir>/README.md -> "<dir>" (directory index) | |
| # docs/<dir>/<file>.md -> "<dir>/<file>" | |
| map_doc_path() { | |
| local doc_path="$1" | |
| local rel="${doc_path#docs/}" | |
| local page_path | |
| case "$rel" in | |
| README.md) | |
| page_path="" | |
| ;; | |
| *) | |
| local base dir stripped | |
| base="$(basename "$rel")" | |
| dir="$(dirname "$rel")" | |
| if [ "$dir" = "." ]; then | |
| dir="" | |
| fi | |
| case "$base" in | |
| index.md | README.md) | |
| page_path="$dir" | |
| ;; | |
| *) | |
| stripped="${base%.md}" | |
| if [ -z "$dir" ]; then | |
| page_path="$stripped" | |
| else | |
| page_path="${dir}/${stripped}" | |
| fi | |
| ;; | |
| esac | |
| ;; | |
| esac | |
| printf '%s' "$page_path" | |
| } | |
| # Page preview URL: branch prefix ($url_prefix) plus the page's | |
| # manifest route. Shared by the checklist and image section. | |
| page_url() { | |
| local filename="$1" page_path url | |
| page_path=$(map_doc_path "$filename") | |
| url="$url_prefix" | |
| if [ -n "$page_path" ]; then | |
| url="${url}/${page_path}" | |
| fi | |
| printf '%s' "$url" | |
| } | |
| # Emits the raw path token of every  and | |
| # <img ... src="path"> in a Markdown file, one per line, as written | |
| # (resolve_ref decides which are repo images). `|| true` keeps a | |
| # no-match grep from tripping set -e. | |
| extract_ref_tokens() { | |
| local file="$1" | |
| # Markdown: capture to the first space or ) to drop a title. | |
| grep -oE '!\[[^]]*\]\([^)[:space:]]+' "$file" 2>/dev/null \ | |
| | sed -E 's/^!\[[^]]*\]\(//' || true | |
| # HTML: single- or double-quoted src, case-insensitive tag/attr. | |
| grep -oiE '<img[^>]+src=("[^"]+"|'\''[^'\'']+'\'')' "$file" 2>/dev/null \ | |
| | sed -E 's/.*src=//I; s/^["'\'']//; s/["'\'']$//' || true | |
| } | |
| # Normalizes a ref from Markdown file $1 to a repo-root-relative | |
| # path (stripping a #fragment, ?query, or <...> wrapper), or | |
| # nothing when external or protocol-relative. realpath -m resolves | |
| # ../ without requiring the target to exist. | |
| resolve_ref() { | |
| local file="$1" ref="$2" dir | |
| ref="${ref%%#*}" | |
| ref="${ref%%\?*}" | |
| ref="${ref#<}" | |
| ref="${ref%>}" | |
| [ -z "$ref" ] && return 0 | |
| case "$ref" in | |
| *://* | //* | mailto:* | data:*) return 0 ;; | |
| esac | |
| dir=$(dirname "$file") | |
| realpath -m --relative-to="$PWD" "${PWD}/${dir}/${ref}" 2>/dev/null || true | |
| } | |
| # Pages that embed the given image, one per line. A page matches | |
| # only when one of its refs resolves to exactly this image, so a | |
| # prose basename mention or a same-basename image in another dir | |
| # won't. grep -rlF is a coarse basename pre-filter. | |
| pages_for_image() { | |
| local image="$1" base candidates file token | |
| base=$(basename "$image") | |
| # -e so a basename beginning with '-' is treated as a pattern, | |
| # not as grep options. | |
| candidates=$(grep -rlF -e "$base" docs --include='*.md' 2>/dev/null \ | |
| | grep -v '^docs/\.style/' || true) | |
| [ -z "$candidates" ] && return 0 | |
| while IFS= read -r file; do | |
| [ -z "$file" ] && continue | |
| while IFS= read -r token; do | |
| [ -z "$token" ] && continue | |
| [ "$(basename "$token")" = "$base" ] || continue | |
| if [ "$(resolve_ref "$file" "$token")" = "$image" ]; then | |
| printf '%s\n' "$file" | |
| break | |
| fi | |
| done < <(extract_ref_tokens "$file") | |
| done <<< "$candidates" | |
| } | |
| # Find the existing comment id once, for both the cleanup and | |
| # upsert paths. The list must propagate a real failure under set -e | |
| # (else the upsert sees "no comment" and posts a duplicate); the | |
| # `|| true` only absorbs head's SIGPIPE. | |
| all_comment_ids=$(list_docs_preview_comments) | |
| existing_id=$(printf '%s\n' "$all_comment_ids" | head -n 1) || true | |
| # Fetch the PR's changed files once, then derive the Markdown and | |
| # image sets from the same payload: one read halves the rate-limit | |
| # cost and avoids two paginated reads straddling a synchronize. | |
| # Captured (not piped) so a gh failure propagates under set -e; jq | |
| # `.[]` streams the per-page arrays, so no --slurp. pulls/files | |
| # caps at 3000 files, which docs PRs never approach. | |
| files_json=$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files") | |
| # Non-removed docs/*.md (outside docs/.style/) as <filename>\t<sha>. | |
| # The blob sha lets a later run detect a page changed since it was | |
| # last listed. | |
| changed_tsv=$(printf '%s' "$files_json" \ | |
| | jq -r '.[] | select(.status != "removed") | select(.filename | test("^docs/.*\\.md$")) | select((.filename | test("^docs/\\.style/")) | not) | [.filename, .sha] | @tsv') | |
| # Changed docs/ images (outside docs/.style/), extensions matched | |
| # case-insensitively. No sha: the image section is stateless. Keep | |
| # the extension set in sync with test-docs-preview-mapper.sh. | |
| changed_images=$(printf '%s' "$files_json" \ | |
| | jq -r '.[] | select(.status != "removed") | select(.filename | test("^docs/.*\\.(png|jpe?g|gif|svg|webp|avif|bmp|ico)$"; "i")) | select((.filename | test("^docs/\\.style/")) | not) | .filename') | |
| # Nothing previewable changed at all: drop any stale comment and | |
| # stop before the manifest read and reference resolution below. | |
| if [ -z "$changed_tsv" ] && [ -z "$changed_images" ]; then | |
| echo "No added/modified Markdown or image files under docs/ (outside docs/.style/) on this push." | |
| cleanup_stale_and_exit | |
| fi | |
| # Navigable pages from the PR-head manifest: every object with a | |
| # "path" key, normalized to docs/... to compare against the PR-file | |
| # and resolved-image paths. This makes the manifest a hard | |
| # dependency: rename the "path" key and allowed_paths comes back | |
| # empty, nothing is eligible, and the comment is deleted, so a | |
| # future manifest refactor must keep this extraction in step. | |
| allowed_paths=$(jq -r '[.. | objects | select(has("path")) | .path] | .[]' docs/manifest.json \ | |
| | sed -E 's#^\./##; s#^#docs/#') | |
| # Keep only changed pages with a manifest route; the rest 404. | |
| eligible_tsv=$(printf '%s\n' "$changed_tsv" | while IFS=$'\t' read -r filename sha; do | |
| [ -z "$filename" ] && continue | |
| if printf '%s\n' "$allowed_paths" | grep -qxF "$filename"; then | |
| printf '%s\t%s\n' "$filename" "$sha" | |
| fi | |
| done) | |
| # Map each changed image to the manifest-routed pages that embed | |
| # it, one "<image>\t<page>" per line. An image with no such page | |
| # yields no pair but is still listed (link-less) below. | |
| image_pairs_tsv=$(printf '%s\n' "$changed_images" | while IFS= read -r image; do | |
| [ -z "$image" ] && continue | |
| pages_for_image "$image" | while IFS= read -r page; do | |
| [ -z "$page" ] && continue | |
| if printf '%s\n' "$allowed_paths" | grep -qxF "$page"; then | |
| printf '%s\t%s\n' "$image" "$page" | |
| fi | |
| done | |
| done) | |
| # Nothing to preview: no manifest-routed changed page and no | |
| # changed image (a changed image always renders, so only a | |
| # Markdown-only PR with nothing routed lands here). Drop and stop. | |
| if [ -z "$eligible_tsv" ] && [ -z "$changed_images" ]; then | |
| echo "No changed Markdown pages resolve to a docs/manifest.json route, and no images changed under docs/ (outside docs/.style/)." | |
| echo "(If pages you expect are missing, check docs/manifest.json's schema: an empty allowlist looks identical to no eligible pages.)" | |
| cleanup_stale_and_exit | |
| fi | |
| # May be an empty array: an image-only PR has no changed pages, | |
| # in which case the checklist section is omitted and only the | |
| # image section renders. | |
| eligible_json=$(printf '%s\n' "$eligible_tsv" \ | |
| | jq -R -s '[splits("\n") | select(length > 0) | split("\t") | {filename: .[0], sha: .[1]}]') | |
| # [{image, pages:[...]}] sorted by image, pages per image deduped | |
| # and sorted. An image with no embedding page keeps pages:[] so it | |
| # still renders link-less (icons live only in manifest icon_path; | |
| # some images are embedded only by non-navigable pages). No changed | |
| # image yields [], so no image section. Keep in sync with | |
| # test-docs-preview-mapper.sh. | |
| image_section_json=$(jq -n \ | |
| --argjson images "$(printf '%s\n' "$changed_images" | jq -R -s '[splits("\n") | select(length > 0)]')" \ | |
| --argjson pairs "$(printf '%s\n' "$image_pairs_tsv" | jq -R -s '[splits("\n") | select(length > 0) | split("\t") | {image: .[0], page: .[1]}]')" \ | |
| '($pairs | group_by(.image) | map({key: .[0].image, value: (map(.page) | unique)}) | from_entries) as $by | |
| | [$images[] | {image: ., pages: ($by[.] // [])}] | |
| | sort_by(.image)') | |
| # Fetch the body right before reading its checkbox state, so a | |
| # reviewer's toggle isn't overwritten by a stale read from several | |
| # API calls earlier. `|| true`: a transient error degrades to | |
| # treating every page as new. A toggle landing between this read | |
| # and the PATCH below is lost but reappears on the next push. | |
| existing_body="" | |
| if [ -n "$existing_id" ]; then | |
| existing_body=$(gh api "repos/${REPO}/issues/comments/${existing_id}" --jq '.body' || true) | |
| if [ -z "$existing_body" ]; then | |
| # A docs-preview comment always has a body, so an empty read | |
| # against a known id is a transient fetch failure. Log the | |
| # resulting checkbox reset so it isn't silent; self-heals next | |
| # push. | |
| echo "Could not read existing comment ${existing_id}; checkbox state resets this push (transient, self-heals)." >&2 | |
| fi | |
| fi | |
| # Recover state from the existing comment, if any: | |
| # - old_state: path -> sha map this workflow wrote last time | |
| # (hidden marker). | |
| # - old_checked: path -> checked map from the live checkbox | |
| # glyphs, where a reviewer's clicks land (GitHub persists a | |
| # toggle as a comment-body edit). | |
| old_state_json="{}" | |
| old_checked_json="{}" | |
| if [ -n "$existing_body" ]; then | |
| old_state_b64=$(printf '%s\n' "$existing_body" | grep -oE "${STATE_PREFIX}[A-Za-z0-9+/=]+" | sed "s/^${STATE_PREFIX}//") || true | |
| if [ -n "$old_state_b64" ]; then | |
| # Guard the decode: a truncated/corrupt marker must degrade to | |
| # "treat every page as new", not kill the run (base64 -d and jq | |
| # run under set -e). The non-empty check matters because on | |
| # jq < 1.7 `jq -e` exits 0 on empty input, so the type check | |
| # alone would accept "" and `--argjson old_state ""` would | |
| # abort. Else keep {}. | |
| decoded=$(printf '%s' "$old_state_b64" | base64 -d 2>/dev/null || true) | |
| if [ -n "$decoded" ] && printf '%s' "$decoded" | jq -e 'type == "object"' >/dev/null 2>&1; then | |
| old_state_json="$decoded" | |
| fi | |
| fi | |
| # shellcheck disable=SC2016 # backticks below are literal Markdown code-span delimiters, not command substitution. | |
| old_checked_json=$(printf '%s\n' "$existing_body" \ | |
| | grep -oE '^[[:space:]]*- \[[ xX]\] \[`[^`]+`\]' \ | |
| | sed -E 's/^[[:space:]]*- \[([ xX])\] \[`([^`]+)`\]/\1\t\2/' \ | |
| | jq -R -s '[splits("\n") | select(length > 0) | split("\t") | {(.[1]): (.[0] | test("x"; "i"))}] | add // {}') || true | |
| fi | |
| # Each page's checked state: carry the live checkbox forward only | |
| # if its blob sha is unchanged since the last state marker. New | |
| # pages, and pages whose sha moved, start unchecked. | |
| final_rows=$(jq -n \ | |
| --argjson eligible "$eligible_json" \ | |
| --argjson old_state "$old_state_json" \ | |
| --argjson old_checked "$old_checked_json" \ | |
| '[ | |
| $eligible[] | . as $f | | |
| ($old_state[$f.filename] // null) as $prev_sha | | |
| (if $prev_sha != null and $prev_sha == $f.sha | |
| then ($old_checked[$f.filename] // false) | |
| else false | |
| end) as $checked | | |
| {filename: $f.filename, sha: $f.sha, checked: $checked} | |
| ] | sort_by(.filename)') | |
| # URL-encode the branch so slashes and special characters don't | |
| # break the preview URL. The page path is left as-is: its slashes | |
| # are real path separators that must be preserved. | |
| encoded_branch=$(jq -rn --arg b "$BRANCH" '$b | @uri') | |
| url_prefix="https://coder.com/docs/@${encoded_branch}" | |
| total_pages=$(printf '%s' "$final_rows" | jq 'length') | |
| # Render the "Changed images" section from image_section_json: each | |
| # image as a bullet with a nested preview link per embedding page | |
| # (an image with no page renders link-less). Self-limits to a byte | |
| # budget at image granularity (always emits the first image) and to | |
| # IMAGE_PAGES_MAX pages per image, with "and N more" notes on | |
| # overflow. Reads $image_section_json, $url_prefix (via page_url), | |
| # REPO, PR_NUMBER, IMAGE_* caps. Keep in sync with | |
| # test-docs-preview-mapper.sh. | |
| render_image_section() { | |
| local budget="$1" count intro header out="" shown=0 i=0 | |
| local image page_count entry j page url dropped candidate | |
| count=$(printf '%s' "$image_section_json" | jq 'length') | |
| if [ "$count" -eq 0 ]; then | |
| return 0 | |
| fi | |
| intro="These images changed. Each is listed with the navigable page(s) that embed it, so you can open the preview and see the new image in context. An image with no page listed isn't embedded by any navigable docs page. These links are informational and have no review checkboxes." | |
| header="#### Changed images"$'\n\n'"${intro}"$'\n\n' | |
| while [ "$i" -lt "$count" ]; do | |
| image=$(printf '%s' "$image_section_json" | jq -r --argjson i "$i" '.[$i].image') | |
| page_count=$(printf '%s' "$image_section_json" | jq --argjson i "$i" '.[$i].pages | length') | |
| # The backticks are literal Markdown code-span delimiters. | |
| entry="- \`${image}\`"$'\n' | |
| j=0 | |
| while [ "$j" -lt "$page_count" ] && [ "$j" -lt "$IMAGE_PAGES_MAX" ]; do | |
| page=$(printf '%s' "$image_section_json" | jq -r --argjson i "$i" --argjson j "$j" '.[$i].pages[$j]') | |
| url=$(page_url "$page") | |
| entry="${entry} - [\`${page}\`](${url})"$'\n' | |
| j=$((j + 1)) | |
| done | |
| if [ "$page_count" -gt "$IMAGE_PAGES_MAX" ]; then | |
| entry="${entry} - _and $((page_count - IMAGE_PAGES_MAX)) more page(s) embedding this image_"$'\n' | |
| fi | |
| # Keep the first image unconditionally (an empty section under a | |
| # header is self-contradicting); measure each later image's | |
| # would-be section and stop before it exceeds budget. | |
| if [ "$shown" -gt 0 ]; then | |
| candidate="${header}${out}${entry}" | |
| if [ "$(printf '%s' "$candidate" | LC_ALL=C wc -c)" -gt "$budget" ]; then | |
| break | |
| fi | |
| fi | |
| out="${out}${entry}" | |
| shown=$((shown + 1)) | |
| i=$((i + 1)) | |
| done | |
| dropped=$((count - shown)) | |
| if [ "$dropped" -gt 0 ]; then | |
| out="${out}"$'\n'"_and ${dropped} more changed image(s) not listed to stay under GitHub's comment size limit. See the [Files tab](https://github.com/${REPO}/pull/${PR_NUMBER}/files) for the full list._"$'\n' | |
| fi | |
| printf '%s%s' "$header" "$out" | |
| } | |
| # Rendered once (independent of page count) so the checklist binary | |
| # search below sizes pages against a body that already includes it. | |
| image_section=$(render_image_section "$IMAGE_SECTION_BUDGET") | |
| # Assemble the body for the first N pages: optional checklist, | |
| # optional image section, hidden base64 state marker. Checklist and | |
| # marker derive from the same N rows, so this prints exactly the | |
| # posted bytes, letting the caller size by measuring not estimating. | |
| # An image-only PR has zero pages: only images render. | |
| build_comment_body() { | |
| local n="$1" rows state_json state_b64 checklist="" intro page_block="" | |
| local filename checked url box omitted | |
| rows=$(printf '%s' "$final_rows" | jq -c --argjson n "$n" '.[:$n]') | |
| state_json=$(printf '%s' "$rows" | jq -c 'map({(.filename): .sha}) | add // {}') | |
| state_b64=$(printf '%s' "$state_json" | base64 -w0) | |
| if [ "$total_pages" -gt 0 ]; then | |
| while IFS=$'\t' read -r filename checked; do | |
| [ -z "$filename" ] && continue | |
| url=$(page_url "$filename") | |
| box=" " | |
| if [ "$checked" = "true" ]; then | |
| box="x" | |
| fi | |
| # The backticks are literal Markdown code-span delimiters. | |
| checklist="${checklist}- [${box}] [\`${filename}\`](${url})"$'\n' | |
| done < <(printf '%s' "$rows" | jq -r '.[] | [.filename, (.checked | tostring)] | @tsv') | |
| omitted=$((total_pages - n)) | |
| if [ "$omitted" -gt 0 ]; then | |
| checklist="${checklist}"$'\n'"_and ${omitted} more changed page(s) not listed to stay under GitHub's comment size limit. See the [Files tab](https://github.com/${REPO}/pull/${PR_NUMBER}/files) for the full list._"$'\n' | |
| fi | |
| intro="Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here." | |
| page_block="${intro}"$'\n\n'"${checklist}" | |
| fi | |
| # The identity and state markers are always present so the comment | |
| # stays discoverable and round-trippable, even on an image-only PR | |
| # whose state map is {}. | |
| { | |
| printf '## Docs preview\n\n' | |
| if [ -n "$page_block" ]; then | |
| printf '%s\n' "$page_block" | |
| fi | |
| if [ -n "$image_section" ]; then | |
| printf '%s\n' "$image_section" | |
| fi | |
| printf '%s\n' "$DOCS_PREVIEW_MARKER" | |
| printf '<!-- %s%s -->' "$STATE_PREFIX" "$state_b64" | |
| } | |
| } | |
| # GitHub caps a comment body at 65536 chars. Per-page cost varies | |
| # with path length, so measure the real body instead of estimating: | |
| # keep every page if all fit, else binary search the largest leading | |
| # prefix under budget (body size grows monotonically with page | |
| # count, so the search is well defined). 65000 leaves headroom. | |
| comment_budget=65000 | |
| body_bytes() { LC_ALL=C wc -c; } | |
| if [ "$(build_comment_body "$total_pages" | body_bytes)" -le "$comment_budget" ]; then | |
| keep_pages=$total_pages | |
| else | |
| lo=0 | |
| hi=$((total_pages - 1)) | |
| keep_pages=0 | |
| while [ "$lo" -le "$hi" ]; do | |
| mid=$(((lo + hi) / 2)) | |
| if [ "$(build_comment_body "$mid" | body_bytes)" -le "$comment_budget" ]; then | |
| keep_pages=$mid | |
| lo=$((mid + 1)) | |
| else | |
| hi=$((mid - 1)) | |
| fi | |
| done | |
| fi | |
| # List at least one page when any page changed: the search floors at | |
| # 0 only if a single line exceeds budget (impossible at real path | |
| # lengths), and an empty list under an "and N more" summary is | |
| # self-contradicting. Gated on total_pages so an image-only PR keeps | |
| # its legitimate zero. | |
| if [ "$total_pages" -ge 1 ] && [ "$keep_pages" -lt 1 ]; then | |
| keep_pages=1 | |
| fi | |
| omitted_pages=$((total_pages - keep_pages)) | |
| echo "Listing ${keep_pages} of ${total_pages} changed page(s); ${omitted_pages} omitted for comment size." | |
| comment_body=$(build_comment_body "$keep_pages") | |
| # Upsert: PATCH the existing comment if found, else create. | |
| # existing_id is re-derived from a live list each run (never | |
| # persisted), so a deleted comment lands in the create branch. A | |
| # PATCH failure against a known id therefore almost always means the | |
| # comment still exists and the error is transient: fail instead of | |
| # creating a duplicate; the next push retries. | |
| if [ -n "$existing_id" ]; then | |
| if gh api --method PATCH \ | |
| "repos/${REPO}/issues/comments/${existing_id}" \ | |
| --raw-field body="$comment_body"; then | |
| echo "Updated existing docs preview comment (id=${existing_id})." | |
| else | |
| echo "Failed to update docs preview comment ${existing_id}; leaving it in place to avoid a duplicate. This is usually a transient API error, and the next push will retry." >&2 | |
| exit 1 | |
| fi | |
| else | |
| gh pr comment "${PR_NUMBER}" \ | |
| --repo "${REPO}" \ | |
| --body "$comment_body" | |
| echo "Created new docs preview comment." | |
| fi |