name: ui-review on: issue_comment: types: [created] pull_request: paths: - ".github/workflows/ui-review.yml" - ".claude/skills/ui-review/**" concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} defaults: run: shell: bash permissions: {} jobs: # Auth gate: only users with admin or maintain role on the repo can trigger # the UI review (plus the Copilot bot). For issue_comment, BOTH the PR author # and the commenter are checked, so the bot never runs for external # contributors (neither author nor commenter). gate: runs-on: ubuntu-slim timeout-minutes: 5 permissions: contents: read # Cheap author_association prefilter so random users can't spawn workflow # runs. The gate step below does the authoritative admin/maintain role check. if: > (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.draft == false && (contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) || (github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot'))) || (github.event_name == 'issue_comment' && github.event.issue.pull_request && ( contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association) || (github.event.issue.user.login == 'Copilot' && github.event.issue.user.type == 'Bot') ) && contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) && (github.event.comment.body == '/ui-review' || startsWith(github.event.comment.body, '/ui-review '))) steps: - name: Check user permission env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} EVENT_NAME: ${{ github.event_name }} PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }} PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type || github.event.issue.user.type }} COMMENTER: ${{ github.event.comment.user.login }} run: | check_user() { local user=$1 local type=$2 if [ "$user" = "Copilot" ] && [ "$type" = "Bot" ]; then echo "Allowing Copilot bot: $user" return 0 fi local role role=$(gh api "repos/$REPO/collaborators/$user/permission" --jq .role_name) \ || { echo "Failed to fetch permission for $user"; exit 1; } case "$role" in admin|maintain) echo "User $user is allowed (admin or maintain role)" ;; *) echo "User $user is not allowed (admin or maintain role required)" exit 1 ;; esac } check_user "$PR_AUTHOR" "$PR_AUTHOR_TYPE" if [ "$EVENT_NAME" = "issue_comment" ]; then check_user "$COMMENTER" "User" fi review: needs: gate runs-on: ubuntu-latest # GITHUB_TOKEN is unused — all GitHub API calls go through the app tokens # minted below. Grant nothing so any future accidental use of GITHUB_TOKEN # fails closed. permissions: {} # Heavy: yarn install + dev server boot + Chrome download + a vision/browser # review loop. The Claude step is capped at 30m within this budget. timeout-minutes: 45 steps: # Two app tokens with split scopes: # - app-token-read (read-only): the ONLY GitHub token exposed to Claude / # agent-browser, so prompt-injection in the diff or a rendered page can't # trigger comments, approvals, or any PR mutation. # - app-token-write (write): used only by the React/Post/Report steps we control. - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 id: app-token-read with: client-id: ${{ secrets.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} permission-contents: read permission-pull-requests: read - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 if: github.event_name == 'issue_comment' id: app-token-write with: client-id: ${{ secrets.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} permission-actions: read permission-contents: read permission-pull-requests: write - name: Resolve job URL if: github.event_name == 'issue_comment' env: GH_TOKEN: ${{ steps.app-token-write.outputs.token }} REPO: ${{ github.repository }} RUN_ID: ${{ github.run_id }} RUN_ATTEMPT: ${{ github.run_attempt }} PR_NUMBER: ${{ github.event.issue.number }} run: | # first(...) // empty: pick exactly one id, or output nothing if no match. JOB_ID=$(gh api "repos/$REPO/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT/jobs" \ --jq 'first(.jobs[] | select(.name == "review") | .id) // empty') if [ -n "$JOB_ID" ]; then JOB_RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID/job/$JOB_ID?pr=$PR_NUMBER" else JOB_RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID?pr=$PR_NUMBER" fi echo "JOB_RUN_URL=$JOB_RUN_URL" >> "$GITHUB_ENV" - name: React to comment if: github.event_name == 'issue_comment' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.app-token-write.outputs.token }} retries: 3 script: | const { comment } = context.payload; // Anchor the status block on a stable marker so a workflow re-run replaces // it instead of appending a duplicate. The original comment never contains it. const MARKER = ''; const base = comment.body.split(MARKER)[0].replace(/\s+$/, ''); const message = `🎨 [UI Review running...](${process.env.JOB_RUN_URL})`; const updatedBody = `${base}\n\n${MARKER}\n\n---\n\n${message}`; await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: comment.id, body: updatedBody, }); - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.app-token-read.outputs.token }} # Review the post-merge UI. Depth 2 so HEAD^1 (the merge base parent) # is reachable for the skill's `git diff HEAD^1` and `git show HEAD^1:`. ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/merge fetch-depth: 2 - uses: ./.github/actions/free-disk-space - uses: ./.github/actions/setup-python with: pin-micro-version: false - uses: ./.github/actions/setup-node - name: Parse review arguments id: prompt env: COMMENT_BODY: ${{ github.event_name == 'issue_comment' && github.event.comment.body || '' }} run: | cat > /tmp/parse_args.py <<'EOF' import argparse import shlex import sys MODEL_CHOICES = [ "fable", "opus", "sonnet", "haiku", "claude-fable-5", "claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5", ] EFFORT_CHOICES = ["low", "medium", "high", "xhigh", "max"] body = sys.stdin.read().strip().removeprefix("/ui-review") try: tokens = shlex.split(body) except ValueError: tokens = [] parser = argparse.ArgumentParser(add_help=False) # -m/--model and -e/--effort mirror the /review command. parser.add_argument("-m", "--model", choices=MODEL_CHOICES, default="claude-opus-4-7") parser.add_argument("-e", "--effort", choices=EFFORT_CHOICES, default="high") args = parser.parse_args(tokens) sys.stdout.write(f"model={args.model}\neffort={args.effort}\n") EOF echo "$COMMENT_BODY" | python /tmp/parse_args.py >> "$GITHUB_OUTPUT" - name: Launch dev server with demo data env: MLFLOW_TRACKING_URI: sqlite:////tmp/ui-review/mlflow.db CI: "false" # CRA dev server treats warnings as errors when CI=true run: | mkdir -p /tmp/ui-review # Pre-populate the GenAI demo dataset (offline, no API key) so pages aren't empty. uv run --frozen python -c "from mlflow.demo import generate_all_demos; generate_all_demos(refresh=True)" # Install a credential-free stub `claude` so the Assistant provider's auth # probe passes and its chat panel renders (the dev server has no # ANTHROPIC_API_KEY). Scoped to the dev server's own PATH; the real CLI in # the UI Review step below is untouched. Always on: it's harmless for # non-Assistant reviews and keeps the Assistant reviewable on any PR. # run_dev_server.py starts the backend + `yarn start` frontend (much faster than # a full `yarn build`) and honors MLFLOW_TRACKING_URI for the store. uv run --frozen dev/run_dev_server.py --stub-providers claude > /tmp/dev-server.log 2>&1 & echo "DEV_PID=$!" >> "$GITHUB_ENV" APP_URL="" for _ in $(seq 1 120); do [ -z "$APP_URL" ] && APP_URL=$(grep -oE 'Frontend: http://localhost:[0-9]+' /tmp/dev-server.log | head -1 | grep -oE 'http://localhost:[0-9]+' || true) [ -n "$APP_URL" ] && curl -fsS "$APP_URL" >/dev/null 2>&1 && break sleep 5 done if [ -z "$APP_URL" ] || ! curl -fsS "$APP_URL" >/dev/null 2>&1; then echo "::error::dev server did not become ready"; tail -80 /tmp/dev-server.log; exit 1 fi echo "APP_URL=$APP_URL" >> "$GITHUB_ENV" - name: Set up agent-browser run: | # `npm i -g` (alias form, which policy.rego's lockfile rule doesn't flag) puts # agent-browser on PATH directly. `--before` keeps the repo's 7-day supply-chain # cooldown (there's no .npmrc setting min-release-age). npm i -g agent-browser --before="$(date -u -d '7 days ago' +%Y-%m-%d)" agent-browser install # downloads Chrome (headless by default) agent-browser --version - name: Install Claude CLI run: | .claude/scripts/install-claude.sh - name: Verify Claude CLI run: | claude --version - name: UI Review id: review env: # Read-only token: Claude / agent-browser can read the PR but cannot post anything. GH_TOKEN: ${{ steps.app-token-read.outputs.token }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} APP_URL: ${{ env.APP_URL }} MODEL: ${{ steps.prompt.outputs.model }} EFFORT: ${{ steps.prompt.outputs.effort }} AGENT_BROWSER_SCREENSHOT_DIR: /tmp/ui-review-shots AGENT_BROWSER_DEFAULT_TIMEOUT: "30000" run: | mkdir -p "$AGENT_BROWSER_SCREENSHOT_DIR" OUTPUT_FILE="/tmp/ui-output.jsonl" # PIPESTATUS[0] captures claude's exit (not stream.sh's), so a 124 timeout isn't masked. EXIT_CODE=0 timeout 30m claude \ --model "$MODEL" \ --effort "$EFFORT" \ --max-budget-usd 8 \ --print \ --verbose \ --output-format stream-json \ "/ui-review $REPO $PR_NUMBER $APP_URL" \ | .claude/scripts/stream.sh "$OUTPUT_FILE" || EXIT_CODE="${PIPESTATUS[0]}" if [ "${EXIT_CODE:-0}" -eq 124 ]; then echo "Warning: Claude command timed out after 30 minutes" elif [ "${EXIT_CODE:-0}" -ne 0 ]; then echo "Error: Claude command failed with exit code $EXIT_CODE" cat "$OUTPUT_FILE" exit "$EXIT_CODE" fi - name: Stop dev server if: always() run: | # The review loop is the only consumer of the dev server, so stop it as # soon as Claude finishes; the remaining steps (check, upload, comment) # don't need it. always() so it runs even if the review step failed or # timed out. Guard on a non-empty PID so a bare `kill 0` can't signal the # whole process group. if [ -n "${DEV_PID:-}" ]; then kill "$DEV_PID" 2>/dev/null || true fi - name: Check UI review output run: | # The skill writes Markdown (no JSON schema): just confirm it produced a # non-empty body. The workflow wraps it with the header/footer below. if [ ! -s /tmp/ui-review-body.md ]; then echo "::error::Claude did not produce a non-empty /tmp/ui-review-body.md" exit 1 fi echo "::group::UI review body" cat /tmp/ui-review-body.md echo "::endgroup::" - name: Collect stray screenshots if: always() run: | # Safety net: agent-browser writes a bare-filename screenshot to the # browser daemon's cwd (the workspace root), not $AGENT_BROWSER_SCREENSHOT_DIR. # The skill is instructed to use absolute paths, but sweep any depth-1 # PNGs into the shots dir so evidence is never silently dropped. mkdir -p /tmp/ui-review-shots find "$GITHUB_WORKSPACE" -maxdepth 1 -name '*.png' -exec mv -f {} /tmp/ui-review-shots/ \; 2>/dev/null || true - name: Upload screenshots if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: # Stable name (artifacts are per-run) so it matches the comment text below. # overwrite handles workflow re-runs (same run id, new attempt). name: ui-review-screenshots path: /tmp/ui-review-shots retention-days: 7 if-no-files-found: ignore overwrite: true - name: Post UI review comment if: github.event_name == 'issue_comment' env: GH_TOKEN: ${{ steps.app-token-write.outputs.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.issue.number }} ARTIFACT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | # The skill writes only the summary + findings (/tmp/ui-review-body.md). # The workflow owns the run-specific wrapping: the find-and-update marker, # the header, the screenshots-artifact link, and the provenance footer. MARKER="" { echo "$MARKER" echo "## 🎨 UI Review" echo cat /tmp/ui-review-body.md if find /tmp/ui-review-shots -maxdepth 1 -name '*.png' 2>/dev/null | grep -q .; then echo echo "📎 Screenshots: see the **ui-review-screenshots** artifact on [this run](${ARTIFACT_URL})." fi echo echo "🤖 Generated with Claude" } > /tmp/ui-review-comment.md # Find-and-update an existing summary comment (by marker) so workflow # re-runs replace it instead of appending a duplicate; create if none. CID=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \ --jq "[.[] | select(.body | startswith(\"$MARKER\")) | .id] | last // empty") if [ -n "$CID" ]; then gh api "repos/$REPO/issues/comments/$CID" -X PATCH -F body=@/tmp/ui-review-comment.md else gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file /tmp/ui-review-comment.md fi - name: Report review results if: always() && github.event_name == 'issue_comment' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: JOB_STATUS: ${{ job.status }} MODEL: ${{ steps.prompt.outputs.model }} with: github-token: ${{ steps.app-token-write.outputs.token }} retries: 3 script: | const fs = require('fs'); const { owner, repo } = context.repo; const { comment } = context.payload; const jobStatus = process.env.JOB_STATUS; const model = process.env.MODEL; const outputPath = '/tmp/ui-output.jsonl'; let claudeOutput = ''; try { if (fs.existsSync(outputPath)) { claudeOutput = fs.readFileSync(outputPath, 'utf8'); } } catch (e) { console.log('Failed to read Claude output file:', e); } let resultEvent = null; if (claudeOutput) { try { const events = claudeOutput.trim().split('\n').filter(Boolean).map(JSON.parse); resultEvent = events.findLast(({ type }) => type === 'result'); } catch (e) { console.log('Failed to parse Claude output as JSON:', e); } } const jobRunUrl = process.env.JOB_RUN_URL; const failed = jobStatus !== 'success' || resultEvent?.is_error; const icon = failed ? '❌' : '✅'; const verb = failed ? 'failed' : 'completed'; let resultLine = `${icon} [UI Review](${jobRunUrl}) ${verb}`; if (resultEvent) { const seconds = (resultEvent.duration_ms / 1000).toFixed(1); const tokens = (resultEvent.usage?.input_tokens ?? 0) + (resultEvent.usage?.output_tokens ?? 0); const cost = (Math.round(resultEvent.total_cost_usd * 100) / 100).toFixed(2); resultLine += ` (${model}, ${seconds}s, ${resultEvent.num_turns} turns, ${tokens} tokens, $${cost})`; if (resultEvent.is_error && /529|Overloaded/i.test(resultEvent.result || '')) { resultLine += `\n\nThe Claude API is overloaded. Check [status.claude.com](https://status.claude.com/) and retry.`; } } else if (failed) { resultLine += `\n\nSee [workflow logs](${jobRunUrl}) for details.`; } const { data: currentComment } = await github.rest.issues.getComment({ owner, repo, comment_id: comment.id, }); const MARKER = ''; const originalBody = currentComment.body.split(MARKER)[0].replace(/\s+$/, ''); const updatedBody = `${originalBody}\n\n${MARKER}\n\n---\n\n${resultLine}`; await github.rest.issues.updateComment({ owner, repo, comment_id: comment.id, body: updatedBody, }); - name: Redact secrets from transcript if: always() env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GH_TOKEN_READ: ${{ steps.app-token-read.outputs.token }} GH_TOKEN_WRITE: ${{ steps.app-token-write.outputs.token }} run: | python <<'PY' import os import sys from pathlib import Path path = Path("/tmp/ui-output.jsonl") if not path.exists(): sys.exit(0) data = path.read_text() for name in ("ANTHROPIC_API_KEY", "GH_TOKEN_READ", "GH_TOKEN_WRITE"): if val := os.environ.get(name): data = data.replace(val, "***") path.write_text(data) PY - name: Upload Claude stream transcript if: always() continue-on-error: true uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ui-review-stream-${{ github.run_id }}-${{ github.run_attempt }} path: /tmp/ui-output.jsonl retention-days: 1 if-no-files-found: ignore