From 8ac6eae44fa151f2e3fabd3d59cb877be520411d Mon Sep 17 00:00:00 2001 From: atani Date: Sat, 18 Apr 2026 23:12:00 +0900 Subject: [PATCH 1/2] feat: add --session/--keep-session/--url-only, revive for GHE React UI Solves two long-standing problems. 1. Everyone hits SAML re-auth several times a day when gh-attach drives the browser. session-stop tore down the browser between invocations, and session-only cookies died with it. Add --session NAME + --keep-session so a single persistent playwright-cli session can serve many uploads. One-time login, the rest of the day is free. 2. GitHub Enterprise renders issue/PR pages as a React app. The app does not expose the file-attachment custom element, so Direct mode never worked on GHE. Fix Browser mode to work on GHE. Read the upload URL from textarea.value, not the accessibility snapshot src attribute. Re-fetch the drop-zone ref per image because refs are reissued on every snapshot. Also adds --url-only so callers that manage their own comment posting can use gh-attach purely as an uploader. Example callers: a wrapper that combines several uploads with 'gh pr review --comment'. Other robustness fixes folded in. - playwright-cli sandbox: daemon allowed_roots tracks the parent shell cwd, not the subshell cwd. cd into the first image directory at startup so uploads work from any caller cwd. - snapshot grep: case-insensitive match for the [Snapshot] header. - upload failure detection: use exit code plus specific error strings, not a loose grep that false-positives on 'Console: N errors'. - grep -c vs grep -o: the textarea may contain multiple URLs on a single line, so line-count undercounts. Switch to grep -o | wc -l. - --session requires --persistent on open to avoid silently dropping the on-disk user-data-dir. Bumps VERSION to 0.7.0. --- bin/gh-attach | 220 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 175 insertions(+), 45 deletions(-) diff --git a/bin/gh-attach b/bin/gh-attach index b243afe..75fa80b 100755 --- a/bin/gh-attach +++ b/bin/gh-attach @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -VERSION="0.6.0" +VERSION="0.7.0" # Use bundled playwright-cli if available (Homebrew installation) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -26,6 +26,19 @@ Options: --release-tag Release tag for uploads (default: gh-attach-assets) --browser Force browser mode (skip direct upload) --headed Run browser in headed mode (visible) + --session playwright-cli session name to reuse. Pair this + with --keep-session and a one-time persistent open + (`playwright-cli --session NAME open --persistent URL`) + to keep SSO/SAML login across invocations. + --keep-session Do not stop the browser on exit, preserving + session-only cookies and "remember this device" + state. Required for reusing a --session across + many uploads without re-authenticating. + --url-only Print uploaded asset URLs to stdout (one per line) + and skip both comment creation and update. + --issue is still required because the issue/PR + page is the authenticated context used to reach + the upload form. -h, --help Show help -v, --version Show version @@ -66,6 +79,11 @@ Examples: # With custom body gh-attach --issue 123 --image ./e2e.png --body 'Result: ' + + # Reuse a persistent SSO session (eg. GitHub Enterprise behind SAML): + # one-time: playwright-cli --session ghe open --persistent https://ghe.example.com + # uploads: gh-attach --session ghe --keep-session --url-only \ + # --host ghe.example.com --repo owner/repo --issue 1 --image ./a.png USAGE } @@ -85,6 +103,9 @@ headed="" use_release="" use_browser="" release_tag="gh-attach-assets" +session_name="" +keep_session="" +url_only="" while [[ $# -gt 0 ]]; do case "$1" in @@ -99,6 +120,9 @@ while [[ $# -gt 0 ]]; do --release-tag) release_tag="$2"; shift 2;; --browser) use_browser="true"; shift;; --headed) headed="--headed"; shift;; + --session) session_name="$2"; shift 2;; + --keep-session) keep_session="true"; shift;; + --url-only) url_only="true"; shift;; -h|--help) usage; exit 0;; -v|--version) echo "gh-attach $VERSION"; exit 0;; *) @@ -129,12 +153,45 @@ if [[ -n "$body" && -n "$body_file" ]]; then exit 1 fi +abs_images=() for img in "${images[@]}"; do if [[ ! -f "$img" ]]; then echo "Image not found: $img" >&2 exit 1 fi + # Normalize to absolute path (resolves symlinks so /tmp -> /private/tmp on macOS) + abs=$(cd "$(dirname "$img")" && pwd -P)/$(basename "$img") + abs_images+=("$abs") done +images=("${abs_images[@]}") + +# playwright-cli sandbox: daemon allowed_roots follows the parent shell's cwd +# at invocation time, NOT the subshell's cwd. cd here so uploads work when +# the caller is in an unrelated directory. +if [[ -z "$use_release" && ${#images[@]} -gt 0 ]]; then + cd "$(dirname "${images[0]}")" +fi + +# Build playwright-cli invocation with optional --session prefix. +pw_cli=(playwright-cli) +if [[ -n "$session_name" ]]; then + pw_cli+=(--session "$session_name") +fi + +# --persistent is required when --session is given so that the named session's +# on-disk user-data-dir (and its cookies/SSO state) is reused instead of being +# replaced by an in-memory transient profile. +pw_open_extra=() +if [[ -n "$session_name" ]]; then + pw_open_extra+=(--persistent) +fi + +pw_stop() { + if [[ -n "$keep_session" ]]; then + return 0 + fi + "${pw_cli[@]}" session-stop >/dev/null 2>&1 || true +} if [[ -n "$body_file" ]]; then body="$(cat "$body_file")" @@ -219,10 +276,13 @@ if [[ "$has_placeholder" == "false" ]]; then fi fi -# Create comment with placeholders -comment_info="$(gh api --hostname "$host" -X POST "repos/$repo/issues/$issue/comments" -f body="$body_with_placeholder" --jq '"\(.id)\t\(.html_url)"')" -comment_id="${comment_info%%$'\t'*}" -comment_url="${comment_info#*$'\t'}" +# Create comment with placeholders (skipped in --url-only mode). +# In --url-only mode the caller posts their own comment with the returned URLs. +if [[ -z "$url_only" ]]; then + comment_info="$(gh api --hostname "$host" -X POST "repos/$repo/issues/$issue/comments" -f body="$body_with_placeholder" --jq '"\(.id)\t\(.html_url)"')" + comment_id="${comment_info%%$'\t'*}" + comment_url="${comment_info#*$'\t'}" +fi # Upload images upload_urls=() @@ -277,14 +337,14 @@ elif [[ -n "$use_direct" ]]; then # Navigate to issue/PR page (need file-attachment component for policy fetch) issue_url="https://$host/$repo/issues/$issue" # shellcheck disable=SC2086 - playwright-cli open "$issue_url" $headed >/dev/null 2>&1 + "${pw_cli[@]}" open "${pw_open_extra[@]}" "$issue_url" $headed >/dev/null 2>&1 # Wait for page to load (handle SSO/login redirects) echo "Waiting for page to load..." >&2 max_wait=120 waited=0 while [[ $waited -lt $max_wait ]]; do - current_url=$(playwright-cli eval "window.location.href" 2>/dev/null || echo "") + current_url=$("${pw_cli[@]}" eval "() => window.location.href" 2>/dev/null | grep -A1 '### Result' | tail -1 | tr -d '"' || echo "") if [[ "$current_url" == *"$host"* && "$current_url" != *"/login"* && "$current_url" != *"/sso"* ]]; then echo "Page loaded." >&2 break @@ -298,7 +358,7 @@ elif [[ -n "$use_direct" ]]; then if [[ $waited -ge $max_wait ]]; then echo "Timeout waiting for page load." >&2 - playwright-cli session-stop >/dev/null 2>&1 + pw_stop exit 1 fi @@ -355,7 +415,7 @@ elif [[ -n "$use_direct" ]]; then }" echo "Fetching upload policy: $filename..." >&2 - eval_output=$(playwright-cli eval "$js_code" 2>/dev/null) + eval_output=$("${pw_cli[@]}" eval "$js_code" 2>/dev/null) policy=$(echo "$eval_output" | sed -n '/^### Result$/{ n; p; q; }' | jq -r '.') upload_url=$(echo "$policy" | jq -r '.upload_url') @@ -363,7 +423,7 @@ elif [[ -n "$use_direct" ]]; then if [[ -z "$upload_url" || "$upload_url" == "null" ]]; then echo "Failed to get upload policy for: $img" >&2 - playwright-cli session-stop >/dev/null 2>&1 + pw_stop exit 1 fi @@ -393,7 +453,7 @@ elif [[ -n "$use_direct" ]]; then if [[ -z "$asset_href" || "$asset_href" == "null" ]]; then echo "Failed to upload: $img" >&2 echo "Response: $upload_response" >&2 - playwright-cli session-stop >/dev/null 2>&1 + pw_stop exit 1 fi @@ -401,27 +461,30 @@ elif [[ -n "$use_direct" ]]; then # Reload page for fresh tokens before next upload if [[ "$img" != "${images[-1]}" ]]; then - playwright-cli reload >/dev/null 2>&1 + "${pw_cli[@]}" reload >/dev/null 2>&1 sleep 3 fi done # Stop browser - playwright-cli session-stop >/dev/null 2>&1 + pw_stop else # Browser mode: use playwright-cli + # Works on github.com and on GitHub Enterprise Server (React-based issue pages). + # URL extraction reads textarea.value, not the accessibility snapshot's + # `src=` attribute, because modern GHE inserts markdown / strings into + # the textarea without re-rendering a preview inside the accessibility tree. issue_url="https://$host/$repo/issues/$issue" - # Start browser session and navigate to issue page # shellcheck disable=SC2086 - playwright-cli open "$issue_url" $headed >/dev/null 2>&1 + "${pw_cli[@]}" open "${pw_open_extra[@]}" "$issue_url" $headed >/dev/null 2>&1 - # Wait for GitHub page to load (handle SSO/login redirects) + # Wait for GitHub page to load (handle SSO/login redirects). echo "Waiting for GitHub page to load..." >&2 max_wait=120 waited=0 while [[ $waited -lt $max_wait ]]; do - current_url=$(playwright-cli eval "window.location.href" 2>/dev/null || echo "") + current_url=$("${pw_cli[@]}" eval "() => window.location.href" 2>/dev/null | grep -A1 '### Result' | tail -1 | tr -d '"' || echo "") if [[ "$current_url" == *"$host"* && "$current_url" == *"/issues/"* ]]; then echo "GitHub page loaded." >&2 break @@ -435,55 +498,122 @@ else if [[ $waited -ge $max_wait ]]; then echo "Timeout waiting for GitHub page. Please make sure you are logged in." >&2 - playwright-cli session-stop >/dev/null 2>&1 + pw_stop exit 1 fi - # Scroll down to find the comment form - playwright-cli eval "window.scrollTo(0, document.body.scrollHeight)" >/dev/null 2>&1 + # Scroll so the comment form is in view. + "${pw_cli[@]}" eval "() => window.scrollTo(0, document.body.scrollHeight)" >/dev/null 2>&1 sleep 1 - # Upload each image and collect URLs + # --- helpers: scoped to the browser-mode block --- + + # Find the "Paste, drop, or click to add files" button ref from the latest + # accessibility snapshot. Refs are re-issued each snapshot, so call this + # fresh before every upload. + _gha_find_drop_ref() { + local snap_out snap_file ref + snap_out=$("${pw_cli[@]}" snapshot 2>&1) + snap_file=$(echo "$snap_out" | grep -i '\[snapshot\]' | sed -n 's/.*(\(.*\))/\1/p' | head -1) + [[ -z "$snap_file" || ! -f "$snap_file" ]] && return 1 + ref=$(grep -oE 'button "Paste, drop, or click to add files" \[ref=e[0-9]+\]' "$snap_file" | head -1 | grep -oE 'e[0-9]+' || true) + [[ -z "$ref" ]] && return 1 + echo "$ref" + } + + _gha_textarea_value() { + "${pw_cli[@]}" eval \ + "() => { const ta = document.querySelector('textarea'); return ta ? ta.value : ''; }" \ + 2>&1 | grep -A1 '### Result' | tail -1 | sed 's/^"//; s/"$//' + } + + _gha_clear_textarea() { + "${pw_cli[@]}" eval \ + "() => { const ta = document.querySelector('textarea'); if (ta) { ta.value = ''; ta.dispatchEvent(new Event('input', {bubbles:true})); } }" \ + >/dev/null 2>&1 || true + } + + # Count uploaded asset URLs in an arbitrary string. Multiple URLs may be + # concatenated on the same line inside the textarea, so grep -c (which + # counts lines) would under-count. Use grep -o + wc -l instead. In + # `set -o pipefail` mode a 0-match grep returns non-zero, so append + # `|| true` to keep the pipeline's exit status at 0 on empty input. + _gha_count_urls() { + echo "$1" | grep -oE "https://${host}/user-attachments/assets/[a-f0-9-]+" | wc -l | tr -d ' ' || true + } + + # Remove any stale upload artifacts that another invocation may have left + # in the textarea. This guarantees the count-based progress check works. + _gha_clear_textarea + for img in "${images[@]}"; do - # Find and click the file upload button - snapshot_output=$(playwright-cli snapshot 2>&1) - snapshot_file=$(echo "$snapshot_output" | grep '\[snapshot\]' | sed 's/.*(\(.*\))/\1/') - upload_button_ref=$(grep -o 'button "Paste, drop, or click to add files" \[ref=[^]]*\]' "$snapshot_file" 2>/dev/null | head -1 | grep -o 'ref=[^]]*' | cut -d= -f2) + prev_count=$(_gha_count_urls "$(_gha_textarea_value)") - if [[ -z "$upload_button_ref" ]]; then + if ! drop_ref=$(_gha_find_drop_ref); then echo "Could not find upload button. Make sure you are logged into GitHub." >&2 - playwright-cli session-stop >/dev/null 2>&1 + pw_stop exit 1 fi - playwright-cli click "$upload_button_ref" >/dev/null 2>&1 - sleep 0.5 - - # Upload the file - playwright-cli upload "$img" >/dev/null 2>&1 - sleep 2 + "${pw_cli[@]}" click "$drop_ref" >/dev/null 2>&1 + sleep 1 + + # `playwright-cli upload` still prints "Console: N errors, 0 warnings" + # on success. Detect real failures via exit code and path-specific + # error strings (File access denied / ENOENT) instead of a loose match. + set +e + upload_log=$("${pw_cli[@]}" upload "$img" 2>&1) + upload_rc=$? + set -e + if [[ $upload_rc -ne 0 ]] || echo "$upload_log" | grep -qE 'File access denied|ENOENT'; then + echo "playwright-cli upload failed for $img (rc=$upload_rc)" >&2 + echo "$upload_log" | tail -5 >&2 + echo "Hint: place all images under a directory that the playwright-cli" >&2 + echo "daemon can reach (gh-attach cd's into the first image's dir)." >&2 + pw_stop + exit 1 + fi - # Extract the uploaded image URL from the textbox - snapshot_file=$(playwright-cli snapshot 2>&1 | grep '\[snapshot\]' | sed 's/.*(\(.*\))/\1/') - # Get all URLs and take the last one (most recently uploaded) - upload_url=$(grep -oE 'src="https://[^"]+/user-attachments/assets/[^"]*"' "$snapshot_file" 2>/dev/null | tail -1 | sed 's/src="//;s/"$//') + # Wait for the upload URL to appear in the textarea (GHE also renders + # an "" marker on server-side rejections). + upload_url="" + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + sleep 2 + ta_val=$(_gha_textarea_value) + if echo "$ta_val" | grep -q ' -After: ' +playwright-cli --session ghe open --persistent https://ghe.example.com +# → complete SAML in the browser, check "Trust this device" if offered ``` -### Release mode (no browser needed) +Then upload without re-authenticating for the rest of the day: ```bash -gh-attach --issue 123 --image ./screenshot.png --release +gh attach \ + --host ghe.example.com --repo owner/repo --issue 123 \ + --session ghe --keep-session --browser \ + --image ./screenshot.png ``` -### Direct mode (GHE) - -For hosts configured in `~/.config/gh-attach/config`, direct mode is auto-enabled. This uploads via the upload policies API + curl, producing `user-attachments` URLs without creating release artifacts. +Or print only the URL and post the comment yourself: ```bash -# ~/.config/gh-attach/config -# direct_hosts=your-ghe-host.com +url=$(gh attach \ + --host ghe.example.com --repo owner/repo --issue 123 \ + --session ghe --keep-session --browser --url-only \ + --image ./screenshot.png) -gh-attach --issue 123 --image ./screenshot.png --host your-ghe-host.com --repo owner/repo +echo "Result: " \ + | gh pr review 123 --repo ghe.example.com/owner/repo --comment --body-file - ``` -Use `--browser` to override and force browser mode: - -```bash -gh-attach --issue 123 --image ./screenshot.png --browser -``` +> **Note:** `--browser` is currently required on GHE. The Direct mode depends +> on the classic `file-attachment` custom element, which GHE's React UI does +> not expose. Browser mode works on both. -### From file +## Options -```bash -gh-attach --issue 123 --image ./result.png --body-file report.md -``` +| Option | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `--issue ` | Issue or PR number (required) | +| `--image ` | Image file (can be repeated) | +| `--repo ` | Target repository (default: current repo) | +| `--host ` | GitHub host (auto-detected) | +| `--width ` | Image width in `` tag (default: 800) | +| `--body ` | Comment body text (supports placeholders, see below) | +| `--body-file ` | Read body from file | +| `--release` | Use GitHub Releases API (no browser needed) | +| `--release-tag ` | Release tag for `--release` (default: `gh-attach-assets`) | +| `--browser` | Force Browser mode (skip Direct mode) | +| `--headed` | Show browser window (useful for first login / debugging) | +| `--session ` | playwright-cli session name to reuse. Combine with `--keep-session` and a one-time `playwright-cli --session NAME open --persistent URL`. | +| `--keep-session` | Do not stop the browser on exit. Preserves session-only cookies and "remember this device" state across invocations. | +| `--url-only` | Print uploaded asset URLs to stdout (one per line) and skip comment creation/update entirely. `--issue` is still required as page context. | ## Placeholders -Control where images are inserted in the comment body: +Control where images appear in the comment body. -| Placeholder | Description | +| Placeholder | Meaning | | ---------------------------- | ----------------------------- | | `` | Single image (or first image) | | `` | First image (numbered) | -| `` | Second image | | `` | N-th image | -If no placeholder is present, images are appended to the end. - -## Options - -| Option | Required | Default | Description | -| --------------------- | -------- | ---------------- | ------------------------------------------- | -| `--issue ` | Yes | - | Issue or PR number | -| `--image ` | Yes | - | Image file (can be repeated) | -| `--repo ` | No | current repo | Target repository | -| `--width ` | No | 800 | Image width in pixels | -| `--body ` | No | - | Comment body text | -| `--body-file ` | No | - | Read body from file | -| `--host ` | No | auto-detected | GitHub host (for Enterprise) | -| `--release` | No | - | Use GitHub Releases API (no browser needed) | -| `--release-tag ` | No | gh-attach-assets | Release tag for uploads | -| `--browser` | No | - | Force browser mode (skip direct upload) | -| `--headed` | No | - | Show browser window | +If no placeholder is present, images are appended to the end of the body. ## Upload modes -### Browser mode (default) +### Browser mode (default; required for GHE) -1. Create a comment with placeholder(s) -2. Open GitHub in browser via playwright-cli -3. Upload image(s) using GitHub's native upload UI -4. Extract the uploaded URL(s) -5. Update the comment with `` tags +1. Create a comment with placeholders +2. Open the Issue/PR page via playwright-cli +3. Click the native "Paste, drop, or click to add files" button +4. Upload the image; read the resulting URL from the comment textarea +5. Update the comment with `` tags (or skip if `--url-only`) ### Release mode (`--release`) -1. Create a comment with placeholder(s) -2. Upload image(s) to a GitHub Release via `gh release upload` -3. Update the comment with release download URLs +Uploads are stored on a tagged Release in the repo. No browser needed, but the +URL format is `releases/download/...` (not `user-attachments/assets/...`). -### Direct mode (auto-detected) +### Direct mode (auto-enabled for hosts in config) -1. Create a comment with placeholder(s) -2. Open GitHub in browser via playwright-cli (for authentication) -3. Trigger the file-attachment component to obtain upload policies -4. Upload file(s) via curl to the media server -5. Update the comment with `user-attachments` URLs +Faster than Browser mode because the actual upload goes over `curl` after +playwright-cli has obtained the upload policy from the `file-attachment` +custom element. This mode works on **GitHub.com** but not on current GHE +because GHE's React UI does not expose the `file-attachment` element. -Direct mode is auto-enabled for hosts listed in `~/.config/gh-attach/config`: +Enable Direct mode per host: ``` -direct_hosts=host1.example.com,host2.example.com +# ~/.config/gh-attach/config +direct_hosts=github.com ``` -## Notes +## Configuration file + +``` +~/.config/gh-attach/config +``` + +``` +# Direct mode is auto-selected for these hosts. +# Leave empty (or omit) for Browser mode only. +direct_hosts=github.com +``` -- PR comments use the same API as issue comments (use PR number) -- Images are inserted as HTML: `...` -- Browser session is persisted, so login is only needed once -- Use `--headed` to debug or when login is required +## Troubleshooting + +- **SAML prompt every few hours.** The browser was torn down between + invocations. Run gh-attach with `--session NAME --keep-session`. +- **`File access denied` / `ENOENT`.** Put all images under the same + directory; gh-attach cd's into the first image's parent so the + playwright daemon sees them. +- **`Failed to get upload policy` on GHE.** Direct mode is not supported + on GHE yet. Add `--browser` to force Browser mode (or remove the host + from `direct_hosts`). +- **`Timeout waiting for GitHub page`.** SAML session expired. Re-run + `playwright-cli --session NAME open --persistent URL` and complete the + login flow.