Files
2026-07-13 13:22:34 +08:00

473 lines
20 KiB
YAML

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 = '<!-- ui-review-status -->';
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="<!-- ui-review-summary -->"
{
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 = '<!-- ui-review-status -->';
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