Files
ComfyUI_frontend/.github/workflows/pr-qa.yaml
snomiao 3e923b8f21 feat: upgrade QA pipeline to Gemini 3.x models
- qa-record.ts, qa-analyze-pr.ts: gemini-2.5-flash/pro → gemini-3.1-pro-preview
- qa-video-review.ts, qa-generate-test.ts: gemini-2.5-flash → gemini-3-flash-preview
- pr-qa.yaml: update hardcoded model reference
- Add docs/qa/models.md with model comparison and rationale
2026-03-24 04:53:12 +00:00

1028 lines
49 KiB
YAML

# Automated QA of ComfyUI frontend using Playwright video recordings + Gemini review.
# Architecture:
# resolve-matrix → qa-before (main) ─┐
# → qa-after (PR) ─┴→ report
#
# Before/after run in PARALLEL on separate runners for clean isolation.
# Two modes:
# Focused (qa-changes label): Linux-only, before/after comparison
# Full (qa-full label): 3-OS matrix, after-only
name: 'PR: QA'
on:
# TODO: remove push trigger before merge
push:
branches: [sno-skills, sno-qa-*]
pull_request:
types: [labeled]
branches: [main]
workflow_dispatch:
inputs:
mode:
description: 'QA mode'
type: choice
options: [focused, full]
default: focused
# TODO: restore concurrency group before merge (stuck-run issue on dev branches)
# concurrency:
# group: ${{ github.workflow }}-${{ github.ref }}
# cancel-in-progress: true
jobs:
resolve-matrix:
runs-on: ubuntu-latest
outputs:
os: ${{ steps.set.outputs.os }}
mode: ${{ steps.set.outputs.mode }}
skip: ${{ steps.set.outputs.skip }}
number: ${{ steps.resolve-number.outputs.number }}
target_type: ${{ steps.resolve-number.outputs.target_type }}
before_sha: ${{ steps.resolve-refs.outputs.before_sha }}
after_sha: ${{ steps.resolve-refs.outputs.after_sha }}
steps:
- name: Determine QA mode
id: set
env:
LABEL: ${{ github.event.label.name }}
EVENT_ACTION: ${{ github.event.action }}
EVENT_NAME: ${{ github.event_name }}
INPUT_MODE: ${{ inputs.mode }}
run: |
FULL=false
# Only run on label events if it's one of our labels
if [ "$EVENT_ACTION" = "labeled" ] && \
[ "$LABEL" != "qa-changes" ] && [ "$LABEL" != "qa-full" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
# Full QA triggers
if [ "$EVENT_NAME" = "workflow_dispatch" ] && \
[ "$INPUT_MODE" = "full" ]; then
FULL=true
fi
if [ "$LABEL" = "qa-full" ]; then
FULL=true
fi
if [ "$FULL" = "true" ]; then
echo 'os=["ubuntu-latest","macos-latest","windows-latest"]' >> "$GITHUB_OUTPUT"
echo "mode=full" >> "$GITHUB_OUTPUT"
else
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
echo "mode=focused" >> "$GITHUB_OUTPUT"
fi
echo "Mode: $([ "$FULL" = "true" ] && echo full || echo focused)"
- name: Resolve target number and type
id: resolve-number
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUM: ${{ github.event.pull_request.number }}
BRANCH: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
if [ -n "$PR_NUM" ]; then
NUM="$PR_NUM"
else
NUM=$(gh pr list --repo "$REPO" \
--head "$BRANCH" --state open \
--json number --jq '.[0].number // empty')
if [ -z "$NUM" ]; then
NUM=$(echo "$BRANCH" | sed -n 's/^sno-qa-\([0-9]\+\)$/\1/p')
fi
fi
echo "number=${NUM}" >> "$GITHUB_OUTPUT"
if [ -n "$NUM" ]; then
# Use the API to check if it's a PR (gh pr view can't distinguish)
if gh api "repos/${REPO}/pulls/${NUM}" --jq '.number' >/dev/null 2>&1; then
echo "target_type=pr" >> "$GITHUB_OUTPUT"
echo "Target: PR #$NUM"
else
echo "target_type=issue" >> "$GITHUB_OUTPUT"
echo "Target: Issue #$NUM"
fi
fi
- name: Resolve commit SHAs for immutable references
id: resolve-refs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NUM: ${{ steps.resolve-number.outputs.number }}
TARGET_TYPE: ${{ steps.resolve-number.outputs.target_type }}
REPO: ${{ github.repository }}
run: |
MAIN_SHA=$(gh api "repos/${REPO}/git/ref/heads/main" --jq '.object.sha')
echo "before_sha=${MAIN_SHA}" >> "$GITHUB_OUTPUT"
echo "Main: ${MAIN_SHA:0:7}"
if [ "$TARGET_TYPE" = "pr" ] && [ -n "$NUM" ]; then
PR_SHA=$(gh pr view "$NUM" --repo "$REPO" --json headRefOid --jq '.headRefOid')
echo "after_sha=${PR_SHA}" >> "$GITHUB_OUTPUT"
echo "PR #${NUM}: ${PR_SHA:0:7}"
fi
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Analyze PR — deep analysis via Gemini Pro to generate QA guides
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
analyze-pr:
needs: [resolve-matrix]
if: needs.resolve-matrix.outputs.skip != 'true' && needs.resolve-matrix.outputs.mode == 'focused'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.head_ref || github.ref }}
- name: Setup frontend (scripts only)
uses: ./.github/actions/setup-frontend
with:
include_build_step: false
- name: Run analysis
if: needs.resolve-matrix.outputs.number
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p qa-guides
pnpm exec tsx scripts/qa-analyze-pr.ts \
--pr-number "${{ needs.resolve-matrix.outputs.number }}" \
--repo "${{ github.repository }}" \
--output-dir qa-guides \
--type "${{ needs.resolve-matrix.outputs.target_type }}"
- name: Upload QA guides
if: always() && needs.resolve-matrix.outputs.number
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6.2.0
with:
name: qa-guides-${{ github.run_id }}
path: qa-guides/qa-guide-*.json
retention-days: 14
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# BEFORE recording — main branch frontend on its own runner
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
qa-before:
needs: [resolve-matrix, analyze-pr]
if: >-
always() &&
needs.resolve-matrix.outputs.skip != 'true' &&
needs.resolve-matrix.outputs.mode == 'focused' &&
needs.resolve-matrix.result == 'success'
strategy:
fail-fast: false
matrix:
os: ${{ fromJson(needs.resolve-matrix.outputs.os) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Set QA artifacts path
shell: bash
run: echo "QA_ARTIFACTS=$RUNNER_TEMP/qa-artifacts" >> "$GITHUB_ENV"
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
# Install pnpm/node for PR checkout (needed for tsx scripts later)
- name: Setup frontend (scripts only)
uses: ./.github/actions/setup-frontend
with:
include_build_step: false
- name: Build main branch frontend via worktree
shell: bash
run: |
git worktree add ../main-build origin/main
cd ../main-build
pnpm install --frozen-lockfile || pnpm install
pnpm exec vite build
cd "$GITHUB_WORKSPACE"
mv ../main-build/dist dist
git worktree remove ../main-build --force
echo "Built main branch frontend"
ls -la dist/index.html
- name: Setup ComfyUI server (no launch)
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
- name: Install Playwright browser
shell: bash
run: |
npx playwright install chromium
mkdir -p "$QA_ARTIFACTS"
- name: Get PR diff
if: needs.resolve-matrix.outputs.target_type == 'pr'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr diff ${{ github.event.pull_request.number || '' }} \
--repo ${{ github.repository }} > "${{ runner.temp }}/pr-diff.txt" 2>/dev/null || \
git diff origin/main...HEAD > "${{ runner.temp }}/pr-diff.txt"
echo "Changed files:"
grep '^diff --git' "${{ runner.temp }}/pr-diff.txt" | \
sed 's|diff --git a/||;s| b/.*||' | sort -u
- name: Download QA guide
continue-on-error: true
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: qa-guides-${{ github.run_id }}
path: qa-guides
- name: Start server with main branch frontend
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --front-end-root ../dist &
echo $! > /tmp/comfyui-server.pid
for i in $(seq 1 60); do
curl -sf http://127.0.0.1:8188/api/system_stats >/dev/null 2>&1 && echo "Server ready (main)" && exit 0
sleep 2
done
echo "::error::Server timeout (main)"; exit 1
- name: Pre-seed settings
shell: bash
run: |
curl -sf -X POST http://127.0.0.1:8188/api/users \
-H 'Content-Type: application/json' \
-d '{"username":"qa-ci"}' || echo "User creation failed (may already exist)"
curl -sf -X POST http://127.0.0.1:8188/api/devtools/set_settings \
-H 'Content-Type: application/json' \
-d '{"Comfy.TutorialCompleted":true}' || \
curl -sf -X POST http://127.0.0.1:8188/api/settings \
-H 'Content-Type: application/json' \
-d '{"Comfy.TutorialCompleted":true}' || echo "Settings pre-seed skipped"
- name: Run QA recording
shell: bash
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
TARGET_TYPE: ${{ needs.resolve-matrix.outputs.target_type }}
run: |
MODE="before"
if [ "$TARGET_TYPE" = "issue" ]; then
MODE="reproduce"
fi
QA_GUIDE_FLAG=""
if [ -f qa-guides/qa-guide-before.json ]; then
echo "Using QA guide for $MODE recording"
QA_GUIDE_FLAG="--qa-guide qa-guides/qa-guide-before.json"
fi
DIFF_FLAG=""
if [ -f "${{ runner.temp }}/pr-diff.txt" ]; then
DIFF_FLAG="--diff ${{ runner.temp }}/pr-diff.txt"
fi
pnpm exec tsx scripts/qa-record.ts \
--mode "$MODE" \
$DIFF_FLAG \
--output-dir "$QA_ARTIFACTS" \
--url http://127.0.0.1:8188 \
--test-plan .claude/skills/comfy-qa/SKILL.md \
$QA_GUIDE_FLAG
- name: Collect artifacts
if: always()
shell: bash
run: |
kill "$(cat /tmp/comfyui-server.pid)" 2>/dev/null || true
echo "=== QA BEFORE artifacts ==="
ls -la "$QA_ARTIFACTS/" 2>/dev/null | head -30
- name: Upload QA artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6.2.0
with:
name: qa-before-${{ runner.os }}-${{ github.run_id }}
path: ${{ env.QA_ARTIFACTS }}/
retention-days: 14
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# AFTER recording — PR branch frontend on its own runner
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
qa-after:
needs: [resolve-matrix, analyze-pr]
if: >-
always() &&
needs.resolve-matrix.outputs.skip != 'true' &&
needs.resolve-matrix.result == 'success' &&
needs.resolve-matrix.outputs.target_type == 'pr'
strategy:
fail-fast: false
matrix:
os: ${{ fromJson(needs.resolve-matrix.outputs.os) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Set QA artifacts path
shell: bash
run: echo "QA_ARTIFACTS=$RUNNER_TEMP/qa-artifacts" >> "$GITHUB_ENV"
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
# When triggered via sno-qa-* push, the checkout above gets sno-skills
# (the scripts branch), not the actual PR. Fetch the PR ref and check it out.
- name: Checkout PR head for sno-qa-* triggers
if: >-
!github.head_ref &&
needs.resolve-matrix.outputs.target_type == 'pr' &&
needs.resolve-matrix.outputs.number
shell: bash
env:
PR_NUM: ${{ needs.resolve-matrix.outputs.number }}
run: |
git fetch origin "refs/pull/${PR_NUM}/head"
git checkout FETCH_HEAD
echo "Checked out PR #${PR_NUM} at $(git rev-parse --short HEAD)"
- name: Setup frontend (PR branch)
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup ComfyUI server (no launch)
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
- name: Install Playwright browser
shell: bash
run: |
npx playwright install chromium
mkdir -p "$QA_ARTIFACTS"
- name: Get PR diff
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr diff ${{ github.event.pull_request.number || '' }} \
--repo ${{ github.repository }} > "${{ runner.temp }}/pr-diff.txt" 2>/dev/null || \
git diff origin/main...HEAD > "${{ runner.temp }}/pr-diff.txt"
echo "Changed files:"
grep '^diff --git' "${{ runner.temp }}/pr-diff.txt" | \
sed 's|diff --git a/||;s| b/.*||' | sort -u
- name: Download QA guide
continue-on-error: true
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: qa-guides-${{ github.run_id }}
path: qa-guides
- name: Start server with PR branch frontend
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --front-end-root ../dist &
echo $! > /tmp/comfyui-server.pid
for i in $(seq 1 60); do
curl -sf http://127.0.0.1:8188/api/system_stats >/dev/null 2>&1 && echo "Server ready (PR)" && exit 0
sleep 2
done
echo "::error::Server timeout (PR)"; exit 1
- name: Pre-seed settings
shell: bash
run: |
curl -sf -X POST http://127.0.0.1:8188/api/users \
-H 'Content-Type: application/json' \
-d '{"username":"qa-ci"}' || echo "User creation failed (may already exist)"
curl -sf -X POST http://127.0.0.1:8188/api/devtools/set_settings \
-H 'Content-Type: application/json' \
-d '{"Comfy.TutorialCompleted":true}' || \
curl -sf -X POST http://127.0.0.1:8188/api/settings \
-H 'Content-Type: application/json' \
-d '{"Comfy.TutorialCompleted":true}' || echo "Settings pre-seed skipped"
- name: Run AFTER QA recording
shell: bash
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
QA_GUIDE_FLAG=""
if [ -f qa-guides/qa-guide-after.json ]; then
echo "Using QA guide for after recording"
QA_GUIDE_FLAG="--qa-guide qa-guides/qa-guide-after.json"
fi
pnpm exec tsx scripts/qa-record.ts \
--mode after \
--diff "${{ runner.temp }}/pr-diff.txt" \
--output-dir "$QA_ARTIFACTS" \
--url http://127.0.0.1:8188 \
--test-plan .claude/skills/comfy-qa/SKILL.md \
$QA_GUIDE_FLAG
- name: Collect artifacts
if: always()
shell: bash
run: |
kill "$(cat /tmp/comfyui-server.pid)" 2>/dev/null || true
echo "=== QA AFTER artifacts ==="
ls -la "$QA_ARTIFACTS/" 2>/dev/null | head -30
- name: Upload QA artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6.2.0
with:
name: qa-after-${{ runner.os }}-${{ github.run_id }}
path: ${{ env.QA_ARTIFACTS }}/
retention-days: 14
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Report — merges artifacts, runs Gemini video review, deploys
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
report:
needs: [resolve-matrix, analyze-pr, qa-before, qa-after]
if: >-
always() &&
(needs.qa-after.result == 'success' || needs.qa-before.result == 'success') &&
(github.event.pull_request.number || github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
steps:
- name: Configure git identity
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Resolve target number and type
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUM: ${{ github.event.pull_request.number }}
BRANCH: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
if [ -n "$PR_NUM" ]; then
NUM="$PR_NUM"
else
NUM=$(gh pr list --repo "$REPO" \
--head "$BRANCH" --state open \
--json number --jq '.[0].number // empty')
if [ -z "$NUM" ]; then
NUM=$(echo "$BRANCH" | sed -n 's/^sno-qa-\([0-9]\+\)$/\1/p')
fi
fi
echo "number=${NUM}" >> "$GITHUB_OUTPUT"
if [ -n "$NUM" ]; then
if gh api "repos/${REPO}/pulls/${NUM}" --jq '.number' >/dev/null 2>&1; then
echo "target_type=pr" >> "$GITHUB_OUTPUT"
else
echo "target_type=issue" >> "$GITHUB_OUTPUT"
fi
fi
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download BEFORE artifacts
if: needs.qa-before.result == 'success'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
path: qa-artifacts/before
pattern: qa-before-*
- name: Download AFTER artifacts
if: needs.qa-after.result == 'success'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
path: qa-artifacts/after
pattern: qa-after-*
- name: Merge artifacts into per-OS report directories
run: |
echo "=== Downloaded BEFORE artifacts ==="
find qa-artifacts/before -type f 2>/dev/null | head -20
echo "=== Downloaded AFTER artifacts ==="
find qa-artifacts/after -type f 2>/dev/null | head -20
for os in Linux macOS Windows; do
REPORT_DIR="qa-artifacts/qa-report-${os}-${{ github.run_id }}"
HAS_FILES=false
# Check for before files (flat or in subdirectory)
if [ -d "qa-artifacts/before" ] && find qa-artifacts/before -name '*.webm' -o -name '*.png' 2>/dev/null | grep -q .; then
HAS_FILES=true
fi
# Check for after files
if [ -d "qa-artifacts/after" ] && find qa-artifacts/after -name '*.webm' -o -name '*.png' 2>/dev/null | grep -q .; then
HAS_FILES=true
fi
if [ "$HAS_FILES" = true ]; then
mkdir -p "$REPORT_DIR"
# Copy all before files (handles both flat and nested layouts)
find qa-artifacts/before -type f 2>/dev/null | while read f; do
cp "$f" "$REPORT_DIR/" 2>/dev/null || true
done
# Copy all after files (overwrites duplicates with after versions)
find qa-artifacts/after -type f 2>/dev/null | while read f; do
cp "$f" "$REPORT_DIR/" 2>/dev/null || true
done
echo "Merged $os artifacts into $REPORT_DIR"
ls -la "$REPORT_DIR/" | head -20
break # Only create one report dir (multi-OS not yet supported in parallel mode)
fi
done
- name: Install ffmpeg
run: |
if command -v ffmpeg &>/dev/null; then
echo "ffmpeg already installed"
else
echo "Downloading static ffmpeg..."
TMP=$(mktemp -d)
curl -sL "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" | tar xJ -C "$TMP"
sudo cp "$TMP"/ffmpeg-*/ffmpeg "$TMP"/ffmpeg-*/ffprobe /usr/local/bin/
rm -rf "$TMP"
fi
ffmpeg -version | head -1
ffprobe -version | head -1
- name: Convert videos to mp4
run: |
convert_video() {
local WEBM="$1" MP4="$2"
echo "Converting $WEBM ($(du -h "$WEBM" | cut -f1)) to $MP4"
ffmpeg -y -i "$WEBM" \
-c:v libx264 -preset ultrafast -crf 23 -pix_fmt yuv420p \
-movflags +faststart -g 60 \
"$MP4" 2>&1 | tail -5 \
|| echo "ffmpeg conversion failed for $WEBM (non-fatal)"
[ -f "$MP4" ] && echo "Created: $MP4 ($(du -h "$MP4" | cut -f1))"
}
for dir in qa-artifacts/qa-report-*; do
[ -d "$dir" ] || continue
# Convert after video (qa-session.webm)
for name in qa-session qa-before-session; do
if [ -f "$dir/${name}.webm" ] && [ -s "$dir/${name}.webm" ]; then
convert_video "$dir/${name}.webm" "$dir/${name}.mp4"
fi
done
# Fallback: find any non-empty webm not yet converted
if [ ! -f "$dir/qa-session.mp4" ]; then
WEBM=$(find "$dir" -name '*.webm' -type f -size +0c | head -1)
[ -n "$WEBM" ] && convert_video "$WEBM" "$dir/qa-session.mp4"
fi
done
- name: Build context for video review
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_NUM: ${{ steps.pr.outputs.number }}
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
REPO: ${{ github.repository }}
run: |
if [ -z "$TARGET_NUM" ]; then
echo "No target number available, skipping context"
exit 0
fi
if [ "$TARGET_TYPE" = "issue" ]; then
{
echo "### Issue #${TARGET_NUM}"
gh issue view "$TARGET_NUM" --repo "$REPO" \
--json title,body --jq '"Title: \(.title)\n\nDescription:\n\(.body)"' 2>/dev/null || true
echo ""
echo "### Comments"
gh api "repos/${REPO}/issues/${TARGET_NUM}/comments" \
--jq '.[].body' 2>/dev/null | head -200 || true
echo ""
echo "This video attempts to reproduce a reported bug on the main branch."
} > pr-context.txt
echo "Issue context saved ($(wc -l < pr-context.txt) lines)"
else
{
echo "### PR #${TARGET_NUM}"
gh pr view "$TARGET_NUM" --repo "$REPO" \
--json title,body --jq '"Title: \(.title)\n\nDescription:\n\(.body)"' 2>/dev/null || true
echo ""
echo "### Changed files"
gh pr diff "$TARGET_NUM" --repo "$REPO" 2>/dev/null \
| grep '^diff --git' | sed 's|diff --git a/||;s| b/.*||' | sort -u || true
echo ""
echo "### Diff (truncated to 300 lines)"
gh pr diff "$TARGET_NUM" --repo "$REPO" 2>/dev/null \
| head -300 || true
} > pr-context.txt
echo "PR context saved ($(wc -l < pr-context.txt) lines)"
fi
- name: Run video review
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
TARGET_NUM: ${{ steps.pr.outputs.number }}
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
REPO: ${{ github.repository }}
run: |
mkdir -p video-reviews
PR_CTX_FLAG=""
if [ -f pr-context.txt ]; then
PR_CTX_FLAG="--pr-context pr-context.txt"
fi
TARGET_URL_FLAG=""
if [ -n "$TARGET_NUM" ]; then
if [ "$TARGET_TYPE" = "issue" ]; then
TARGET_URL_FLAG="--target-url https://github.com/${REPO}/issues/${TARGET_NUM}"
else
TARGET_URL_FLAG="--target-url https://github.com/${REPO}/pull/${TARGET_NUM}"
fi
fi
for vid in qa-artifacts/qa-report-*/qa-session.mp4; do
[ -f "$vid" ] || continue
DIR=$(dirname "$vid")
BEFORE_FLAG=""
if [ -f "$DIR/qa-before-session.mp4" ]; then
BEFORE_FLAG="--before-video $DIR/qa-before-session.mp4"
fi
echo "::group::Reviewing $vid"
pnpm exec tsx scripts/qa-video-review.ts \
--artifacts-dir qa-artifacts \
--output-dir video-reviews \
--video-file "$vid" \
--model gemini-3-flash-preview $PR_CTX_FLAG $BEFORE_FLAG $TARGET_URL_FLAG || true
echo "::endgroup::"
done
- name: Generate regression test from QA report
if: needs.resolve-matrix.outputs.mode == 'focused' && steps.pr.outputs.target_type == 'pr'
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ steps.pr.outputs.number }}"
PR_BRANCH="${{ github.head_ref || github.ref_name }}"
if [ -z "$PR_NUM" ]; then
echo "No PR number, skipping test generation"
exit 0
fi
# Find the first QA report
REPORT=$(find video-reviews -name '*-qa-video-report.md' -type f | head -1)
if [ ! -f "$REPORT" ]; then
echo "No QA report found, skipping test generation"
exit 0
fi
# Ensure we have the PR diff
DIFF_FILE="${{ runner.temp }}/pr-diff.txt"
if [ ! -f "$DIFF_FILE" ]; then
gh pr diff "$PR_NUM" --repo "${{ github.repository }}" > "$DIFF_FILE" 2>/dev/null || true
fi
# Generate the test
TEST_NAME="qa-pr${PR_NUM}"
TEST_PATH="browser_tests/tests/${TEST_NAME}.spec.ts"
echo "::group::Generating regression test from QA report"
pnpm exec tsx scripts/qa-generate-test.ts \
--qa-report "$REPORT" \
--pr-diff "$DIFF_FILE" \
--output "$TEST_PATH" || {
echo "Test generation failed (non-fatal)"
exit 0
}
echo "::endgroup::"
# Push to {branch}-add-qa-test
TEST_BRANCH="${PR_BRANCH}-add-qa-test"
git checkout -b "$TEST_BRANCH" HEAD 2>/dev/null || git checkout "$TEST_BRANCH" 2>/dev/null || true
git add "$TEST_PATH"
git commit -m "test: add QA regression test for PR #${PR_NUM}" || {
echo "Nothing to commit"
exit 0
}
git push origin "$TEST_BRANCH" --force-with-lease || echo "Push failed (non-fatal)"
echo "Pushed regression test to branch: $TEST_BRANCH"
- name: Deploy to Cloudflare Pages
id: deploy-videos
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
RAW_BRANCH: ${{ github.head_ref || github.ref_name }}
BEFORE_SHA: ${{ needs.resolve-matrix.outputs.before_sha }}
AFTER_SHA: ${{ needs.resolve-matrix.outputs.after_sha }}
TARGET_NUM: ${{ steps.pr.outputs.number }}
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
REPO: ${{ github.repository }}
run: |
npm install -g wrangler@4.74.0 >/dev/null 2>&1
DEPLOY_DIR=$(mktemp -d)
mkdir -p "$DEPLOY_DIR"
for os in Linux macOS Windows; do
DIR="qa-artifacts/qa-report-${os}-${{ github.run_id }}"
for prefix in qa qa-before; do
VID="${DIR}/${prefix}-session.mp4"
if [ -f "$VID" ]; then
DEST="$DEPLOY_DIR/${prefix}-${os}.mp4"
cp "$VID" "$DEST"
echo "Found ${prefix} ${os} video ($(du -h "$VID" | cut -f1))"
fi
done
# Generate GIF thumbnail from after video
if [ -f "$DEPLOY_DIR/qa-${os}.mp4" ]; then
ffmpeg -y -ss 10 -i "$DEPLOY_DIR/qa-${os}.mp4" -t 8 \
-vf "fps=8,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=64[p];[s1][p]paletteuse=dither=bayer" \
-loop 0 "$DEPLOY_DIR/qa-${os}-thumb.gif" 2>/dev/null \
|| echo "GIF generation failed for ${os} (non-fatal)"
fi
done
# Build video cards and report sections
CARDS=""
ICONS_Linux="&#x1F427;" ICONS_macOS="&#x1F34E;" ICONS_Windows="&#x1FA9F;"
CARD_COUNT=0
DL_ICON="<svg width=14 height=14 viewBox='0 0 24 24' fill=none stroke=currentColor stroke-width=2><path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/><polyline points='7 10 12 15 17 10'/><line x1=12 y1=15 x2=12 y2=3'/></svg>"
for os in Linux macOS Windows; do
eval "ICON=\$ICONS_${os}"
OS_LOWER=$(echo "$os" | tr '[:upper:]' '[:lower:]')
HAS_BEFORE=$([ -f "$DEPLOY_DIR/qa-before-${os}.mp4" ] && echo 1 || echo 0)
HAS_AFTER=$([ -f "$DEPLOY_DIR/qa-${os}.mp4" ] && echo 1 || echo 0)
[ "$HAS_AFTER" = "0" ] && continue
REPORT_FILE="video-reviews/${OS_LOWER}-qa-video-report.md"
REPORT_LINK=""
REPORT_HTML=""
if [ -f "$REPORT_FILE" ]; then
cp "$REPORT_FILE" "$DEPLOY_DIR/report-${OS_LOWER}.md"
REPORT_LINK="<a class=dl href=report-${OS_LOWER}.md><svg width=14 height=14 viewBox='0 0 24 24' fill=none stroke=currentColor stroke-width=2><path d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/><polyline points='14 2 14 8 20 8'/><line x1=16 y1=13 x2=8 y2=13/><line x1=16 y1=17 x2=8 y2=17'/></svg>Report</a>"
REPORT_MD=$(cat "$REPORT_FILE" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
REPORT_HTML="<details class=report open><summary><svg width=14 height=14 viewBox='0 0 24 24' fill=none stroke=currentColor stroke-width=2><circle cx=12 cy=12 r=10/><line x1=12 y1=16 x2=12 y2=12/><line x1=12 y1=8 x2=12.01 y2=8'/></svg> AI Comparative Review</summary><div class=report-body data-md>${REPORT_MD}</div></details>"
fi
if [ "$HAS_BEFORE" = "1" ]; then
# Side-by-side before/after layout
CARDS="${CARDS}<div class='card reveal' style='--i:${CARD_COUNT}'><div class=card-header><span class=platform><span class=icon>${ICON}</span>${os}</span><span class=links>${REPORT_LINK}</span></div><div class=comparison><div class=comp-panel><div class=comp-label>Before <span class=comp-tag>main</span></div><div class=video-wrap><video controls muted autoplay loop preload=metadata><source src=qa-before-${os}.mp4 type=video/mp4></video></div><div class=comp-dl><a class=dl href=qa-before-${os}.mp4 download>${DL_ICON}Before</a></div></div><div class=comp-panel><div class=comp-label>After <span class=comp-tag>PR</span></div><div class=video-wrap><video controls muted autoplay loop preload=metadata><source src=qa-${os}.mp4 type=video/mp4></video></div><div class=comp-dl><a class=dl href=qa-${os}.mp4 download>${DL_ICON}After</a></div></div></div>${REPORT_HTML}</div>"
else
# Single video (full QA mode or no before available)
CARDS="${CARDS}<div class='card reveal' style='--i:${CARD_COUNT}'><div class=video-wrap><video controls muted autoplay loop preload=metadata><source src=qa-${os}.mp4 type=video/mp4></video></div><div class=card-body><span class=platform><span class=icon>${ICON}</span>${os}</span><span class=links><a class=dl href=qa-${os}.mp4 download>${DL_ICON}Download</a>${REPORT_LINK}</span></div>${REPORT_HTML}</div>"
fi
CARD_COUNT=$((CARD_COUNT + 1))
done
# Build commit info and target link for the report header
COMMIT_HTML=""
REPO_URL="https://github.com/${REPO}"
if [ -n "$TARGET_NUM" ]; then
if [ "$TARGET_TYPE" = "issue" ]; then
COMMIT_HTML="<a href=${REPO_URL}/issues/${TARGET_NUM} class=sha title='Issue'>Issue #${TARGET_NUM}</a>"
else
COMMIT_HTML="<a href=${REPO_URL}/pull/${TARGET_NUM} class=sha title='Pull Request'>PR #${TARGET_NUM}</a>"
fi
fi
if [ -n "$BEFORE_SHA" ]; then
SHORT_BEFORE="${BEFORE_SHA:0:7}"
COMMIT_HTML="${COMMIT_HTML:+${COMMIT_HTML} &middot; }<a href=${REPO_URL}/commit/${BEFORE_SHA} class=sha title='main branch'>main @ ${SHORT_BEFORE}</a>"
fi
if [ -n "$AFTER_SHA" ]; then
SHORT_AFTER="${AFTER_SHA:0:7}"
AFTER_LABEL="PR"
[ -n "$TARGET_NUM" ] && AFTER_LABEL="#${TARGET_NUM}"
COMMIT_HTML="${COMMIT_HTML:+${COMMIT_HTML} &middot; }<a href=${REPO_URL}/commit/${AFTER_SHA} class=sha title='PR head commit'>${AFTER_LABEL} @ ${SHORT_AFTER}</a>"
fi
[ -n "$COMMIT_HTML" ] && COMMIT_HTML=" &middot; ${COMMIT_HTML}"
cat > "$DEPLOY_DIR/index.html" <<INDEXEOF
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>QA Session Recordings</title>
<link rel=preconnect href=https://fonts.googleapis.com><link rel=preconnect href=https://fonts.gstatic.com crossorigin><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel=stylesheet>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
:root{--bg:oklch(8% 0.02 265);--surface:oklch(12% 0.02 265);--surface-up:oklch(16% 0.02 265);--fg:oklch(96% 0.01 95);--fg-muted:oklch(65% 0.01 265);--fg-dim:oklch(45% 0.01 265);--primary:oklch(62% 0.21 265);--primary-up:oklch(68% 0.21 265);--primary-glow:oklch(62% 0.15 265);--ok:oklch(62% 0.18 155);--err:oklch(62% 0.22 25);--border:oklch(22% 0.02 265);--border-faint:oklch(15% 0.01 265);--r:0.75rem;--r-lg:1rem;--ease-out:cubic-bezier(0.22,1,0.36,1);--dur-base:250ms;--dur-slow:500ms;--font:'Inter',system-ui,sans-serif;--font-mono:'JetBrains Mono',monospace}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--fg);font-family:var(--font);min-height:100vh;padding:clamp(1.5rem,4vw,3rem) clamp(1rem,3vw,2rem);position:relative}
body::after{content:'';position:fixed;inset:0;pointer-events:none;opacity:.03;background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
.container{max-width:1200px;margin:0 auto}
header{display:flex;align-items:center;gap:1rem;margin-bottom:clamp(1.5rem,4vw,3rem);padding-bottom:1.25rem;border-bottom:1px solid var(--border)}
.header-icon{width:36px;height:36px;display:grid;place-items:center;background:linear-gradient(135deg,oklch(100% 0 0/.06),oklch(100% 0 0/.02));backdrop-filter:blur(12px);border:1px solid oklch(100% 0 0/.1);border-radius:var(--r);flex-shrink:0}
.header-icon svg{color:var(--primary)}
h1{font-size:clamp(1.25rem,2.5vw,1.625rem);font-weight:700;letter-spacing:-.03em;background:linear-gradient(135deg,var(--fg),var(--fg-muted));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.meta{color:var(--fg-dim);font-size:.8125rem;margin-top:.15rem;letter-spacing:.01em}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(480px,100%),1fr));gap:1.5rem}
.card{background:linear-gradient(135deg,oklch(100% 0 0/.05),oklch(100% 0 0/.015));backdrop-filter:blur(16px) saturate(150%);border:1px solid oklch(100% 0 0/.08);border-radius:var(--r-lg);overflow:hidden;transition:border-color var(--dur-base) var(--ease-out),box-shadow var(--dur-base) var(--ease-out),transform var(--dur-base) var(--ease-out)}
.card:hover{border-color:oklch(100% 0 0/.16);box-shadow:0 8px 32px oklch(0% 0 0/.3),inset 0 1px 0 oklch(100% 0 0/.1);transform:translateY(-2px)}
.video-wrap{position:relative;background:oklch(4% 0.01 265);border-bottom:1px solid var(--border-faint)}
.video-wrap video{width:100%;display:block;aspect-ratio:16/9;object-fit:contain}
.card-body{padding:.75rem 1rem;display:flex;align-items:center;justify-content:space-between}
.platform{display:flex;align-items:center;gap:.5rem;font-weight:600;font-size:.9375rem;letter-spacing:-.01em}
.icon{font-size:1.125rem}
.links{display:flex;gap:.5rem}
.dl{color:var(--fg-muted);text-decoration:none;font-size:.75rem;font-weight:500;display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:9999px;border:1px solid var(--border);background:oklch(100% 0 0/.03);transition:all var(--dur-base) var(--ease-out)}
.dl:hover{color:var(--primary-up);border-color:var(--primary);background:oklch(62% 0.21 265/.08)}
.badge{font-size:.6875rem;font-weight:600;padding:.2rem .625rem;border-radius:9999px;text-transform:uppercase;letter-spacing:.05em}
.card-header{padding:.75rem 1rem;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border-faint)}
.comparison{display:grid;grid-template-columns:1fr 1fr;gap:0}
.comp-panel{border-right:1px solid var(--border-faint)}
.comp-panel:last-child{border-right:none}
.comp-label{padding:.4rem .75rem;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-muted);background:oklch(10% 0.01 265);display:flex;align-items:center;gap:.4rem}
.comp-tag{font-size:.6rem;padding:.1rem .4rem;border-radius:9999px;font-weight:600}
.comp-panel:first-child .comp-tag{background:oklch(65% 0.01 265/.15);color:var(--fg-muted);border:1px solid var(--border)}
.comp-panel:last-child .comp-tag{background:oklch(62% 0.18 155/.15);color:var(--ok);border:1px solid oklch(62% 0.18 155/.25)}
.comp-dl{padding:.4rem .75rem;display:flex;justify-content:center}
.report{border-top:1px solid var(--border-faint);padding:.75rem 1rem;font-size:.8125rem}
.report summary{cursor:pointer;color:var(--fg-muted);font-weight:500;display:flex;align-items:center;gap:.4rem;user-select:none;transition:color var(--dur-base) var(--ease-out)}
.report summary:hover{color:var(--fg)}
.report summary svg{flex-shrink:0;opacity:.5}
.report[open] summary{margin-bottom:.75rem;padding-bottom:.5rem;border-bottom:1px solid var(--border-faint)}
.report-body{line-height:1.7;color:oklch(80% 0.01 265);overflow-x:auto}
.report-body h1,.report-body h2{margin:1.25rem 0 .5rem;color:var(--fg);font-size:1rem;font-weight:600;letter-spacing:-.02em;border-bottom:1px solid var(--border-faint);padding-bottom:.4rem}
.report-body h3{margin:.75rem 0 .4rem;color:var(--fg);font-size:.875rem;font-weight:600}
.report-body p{margin:.4rem 0}
.report-body ul,.report-body ol{margin:.4rem 0 .4rem 1.5rem}
.report-body li{margin:.25rem 0}
.report-body code{background:oklch(16% 0.02 265);padding:.125rem .375rem;border-radius:.25rem;font-size:.7rem;font-family:var(--font-mono);border:1px solid var(--border-faint)}
.report-body h3+p>code:first-child{background:oklch(62% 0.22 25/.15);color:var(--err);border-color:oklch(62% 0.22 25/.25)}
.report-body h3+p>code:nth-child(2){background:oklch(62% 0.21 265/.15);color:var(--primary-up);border-color:oklch(62% 0.21 265/.25)}
.report-body h3+p>code:nth-child(3){background:oklch(65% 0.01 265/.15);color:var(--fg-muted);border-color:var(--border)}
.report-body table{width:100%;border-collapse:collapse;margin:.75rem 0;font-size:.75rem;border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
.report-body th,.report-body td{border:1px solid var(--border-faint);padding:.5rem .75rem;text-align:left;vertical-align:top;word-wrap:break-word}
.report-body th{background:oklch(14% 0.02 265);color:var(--fg);font-weight:600;font-size:.6875rem;text-transform:uppercase;letter-spacing:.05em;position:sticky;top:0;white-space:nowrap}
.report-body tr:nth-child(even){background:oklch(10% 0.01 265/.5)}
.report-body tr:hover{background:oklch(16% 0.02 265/.5)}
.report-body strong{color:var(--fg)}
.report-body hr{border:none;border-top:1px solid var(--border-faint);margin:1rem 0}
@keyframes fade-up{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
.reveal{animation:fade-up var(--dur-slow) var(--ease-out) both;animation-delay:calc(var(--i,0) * 120ms)}
@media(prefers-reduced-motion:reduce){.reveal{animation:none}}
@media(max-width:480px){.grid{grid-template-columns:1fr}.card-body{flex-wrap:wrap;gap:.5rem}}
.sha{color:var(--primary);text-decoration:none;font-family:var(--font-mono);font-size:.75rem;font-weight:500;padding:.1rem .4rem;border-radius:.25rem;background:oklch(62% 0.21 265/.08);border:1px solid oklch(62% 0.21 265/.15);transition:all var(--dur-base) var(--ease-out)}
.sha:hover{background:oklch(62% 0.21 265/.15);border-color:var(--primary)}
</style></head><body><div class=container>
<header><div class=header-icon><svg width=20 height=20 viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round stroke-linejoin=round><polygon points="23 7 16 12 23 17 23 7"/><rect x=1 y=5 width=15 height=14 rx=2 ry=2/></svg></div><div><h1>QA Session Recordings</h1><div class=meta>ComfyUI Frontend &middot; Automated QA${COMMIT_HTML}</div></div></header>
<div class=grid>${CARDS}</div>
</div><script>document.querySelectorAll('[data-md]').forEach(el=>{const t=el.textContent;el.removeAttribute('data-md');el.innerHTML=marked.parse(t)})</script></body></html>
INDEXEOF
cat > "$DEPLOY_DIR/404.html" <<'ERROREOF'
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><title>404</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel=stylesheet>
<style>:root{--bg:oklch(8% 0.02 265);--fg:oklch(45% 0.01 265);--err:oklch(62% 0.22 25)}*{margin:0;padding:0;box-sizing:border-box}body{background:var(--bg);color:var(--fg);font-family:'Inter',system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh}div{text-align:center}h1{color:var(--err);font-size:clamp(3rem,8vw,5rem);font-weight:700;letter-spacing:-.04em;margin-bottom:.5rem}p{font-size:1rem;max-width:32ch;line-height:1.5}</style>
</head><body><div><h1>404</h1><p>File not found. The QA recording may have failed or been cancelled.</p></div></body></html>
ERROREOF
BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-28)
URL=$(wrangler pages deploy "$DEPLOY_DIR" \
--project-name="comfy-qa" \
--branch="$BRANCH" 2>&1 \
| grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
echo "url=${URL:-https://${BRANCH}.comfy-qa.pages.dev}" >> "$GITHUB_OUTPUT"
echo "Deployed to: ${URL}"
- name: Post unified QA comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VIDEO_BASE: ${{ steps.deploy-videos.outputs.url }}
QA_MODE: ${{ needs.resolve-matrix.outputs.mode }}
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
BEFORE_SHA: ${{ needs.resolve-matrix.outputs.before_sha }}
AFTER_SHA: ${{ needs.resolve-matrix.outputs.after_sha }}
REPO: ${{ github.repository }}
run: |
RUN="https://github.com/${REPO}/actions/runs/${{ github.run_id }}"
COMMENT_MARKER="<!-- QA_REPORT_COMMENT -->"
MODE_BADGE="🔍 Focused"
if [ "$QA_MODE" = "full" ]; then MODE_BADGE="🔬 Full (3-OS)"; fi
if [ "$TARGET_TYPE" = "issue" ]; then MODE_BADGE="🐛 Issue Reproduce"; fi
# Build commit links
COMMIT_LINE=""
REPO_URL="https://github.com/${REPO}"
if [ -n "$BEFORE_SHA" ] || [ -n "$AFTER_SHA" ]; then
PARTS=""
[ -n "$BEFORE_SHA" ] && PARTS="main [\`${BEFORE_SHA:0:7}\`](${REPO_URL}/commit/${BEFORE_SHA})"
[ -n "$AFTER_SHA" ] && PARTS="${PARTS:+${PARTS} · }PR [\`${AFTER_SHA:0:7}\`](${REPO_URL}/commit/${AFTER_SHA})"
COMMIT_LINE="**Commits**: ${PARTS}"
fi
# Build video section with GIF thumbnails linking to full videos
VIDEO_SECTION=""
for os in Linux macOS Windows; do
GIF_URL="${VIDEO_BASE}/qa-${os}-thumb.gif"
VID_URL="${VIDEO_BASE}/qa-${os}.mp4"
if curl -sf --head "$VID_URL" >/dev/null 2>&1; then
if curl -sf --head "$GIF_URL" >/dev/null 2>&1; then
VIDEO_SECTION="${VIDEO_SECTION}[![${os} QA](${GIF_URL})](${VID_URL})"$'\n'
else
VIDEO_SECTION="${VIDEO_SECTION}[${os} video](${VID_URL})"$'\n'
fi
fi
done
# Build video review section from per-platform reports
VIDEO_REVIEW=""
for f in video-reviews/*-qa-video-report.md; do
[ -f "$f" ] || continue
[ -n "$VIDEO_REVIEW" ] && VIDEO_REVIEW="${VIDEO_REVIEW}
---
"
VIDEO_REVIEW="${VIDEO_REVIEW}$(cat "$f")"
done
VIDEO_REVIEW_SECTION=""
if [ -n "$VIDEO_REVIEW" ]; then
VIDEO_REVIEW_SECTION=$(cat <<REVIEWEOF
<details>
<summary>Video Review</summary>
${VIDEO_REVIEW}
</details>
REVIEWEOF
)
fi
BODY=$(cat <<EOF
${COMMENT_MARKER}
## QA ${MODE_BADGE}
${VIDEO_SECTION}
**Run**: [${RUN}](${RUN}) · [Download artifacts](${RUN}#artifacts) · [All videos](${VIDEO_BASE})
${COMMIT_LINE:+${COMMIT_LINE}
}${VIDEO_REVIEW_SECTION}
EOF
)
PR_NUM="${{ steps.pr.outputs.number }}"
if [ -z "$PR_NUM" ]; then
echo "No PR found, skipping comment"
exit 0
fi
EXISTING=$(gh api "repos/${{ github.repository }}/issues/${PR_NUM}/comments" \
--jq ".[] | select(.body | contains(\"${COMMENT_MARKER}\")) | .id" | head -1)
if [ -n "$EXISTING" ]; then
gh api --method PATCH "repos/${{ github.repository }}/issues/comments/${EXISTING}" \
--field body="$BODY"
elif [ "$TARGET_TYPE" = "issue" ]; then
gh issue comment "$PR_NUM" \
--repo ${{ github.repository }} --body "$BODY"
else
gh pr comment "$PR_NUM" \
--repo ${{ github.repository }} --body "$BODY"
fi
- name: Cleanup old video review comments
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ steps.pr.outputs.number }}"
if [ -z "$PR_NUM" ]; then exit 0; fi
OLD_MARKER="<!-- QA_VIDEO_REVIEW_COMMENT -->"
gh api "repos/${{ github.repository }}/issues/${PR_NUM}/comments" \
--jq ".[] | select(.body | contains(\"${OLD_MARKER}\")) | .id" | \
while read -r comment_id; do
echo "Deleting old video review comment: $comment_id"
gh api --method DELETE "repos/${{ github.repository }}/issues/comments/${comment_id}" || true
done
- name: Remove QA label
if: >-
github.event.label.name == 'qa-changes' ||
github.event.label.name == 'qa-full'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LABEL_NAME: ${{ github.event.label.name }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
REPO: ${{ github.repository }}
run: |
[ -n "$PR_NUMBER" ] && gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$LABEL_NAME"